/* =============================================================================
   Will & Eleise  ·  1 July 2027
   Aesthetic: old money wedding stationery. Engraved, romantic, almost monochrome.
   Type:   Cardo italic (section headings)
           Cardo roman  (dates, venues, times)
           Nunito       (body and letterspaced labels)
   Radius: interactive elements are pill, everything else is square. No
           exceptions.
   ========================================================================== */

:root {
  /* The page is light in every setting. This is stationery, and the ivory is the
     brand, so it does not follow the system's dark preference. */
  color-scheme: light only;

  /* TYPE TEST: Cardo + Nunito.
     --display is Cardo italic, taking the role the script had.
     --serif is Cardo roman, for dates, venues and times.
     To go back to the previous pairing, restore these three and the font <link>:
       --display: "Pinyon Script", cursive;
       --serif:   "Bodoni Moda", Didot, Georgia, serif;
       --sans:    "Jost", "Avenir Next", system-ui, sans-serif;
     ...and reset the .script--* sizes noted below. */
  --display: "Cardo", "Iowan Old Style", Georgia, serif;
  --serif:   "Cardo", "Iowan Old Style", Georgia, serif;
  --sans:    "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;

  /* Palette sampled from the two watercolour plates, then warmed toward
     champagne, so the page and the artwork are the same material. */
  /* The two section grounds, measured from the outer edge pixels of the artwork
     that sits on each, so no plate boundary shows.
       --paper      matches westminster.webp   ("The Day")
       --paper-alt  matches travel-map.webp    ("Getting There")
     Swap either artwork and these need remeasuring from its border, not from its
     average: the average is pulled off by the painting itself. */
  --paper:      #fcf9f3;   /* cream */
  --paper-alt:  #f8eee4;   /* champagne */
  /* The artwork multiplies straight onto the page, so it needs no plate. */
  --plate-bg:   transparent;
  --plate-pad:  0rem;
  --ink:        #1a1817;
  --ink-2:      #4f4a44;
  --ink-3:      #6f685f;   /* 4.9:1 on the champagne ground, so small notes pass AA */
  --line:       rgb(26 24 23 / 0.16);
  --line-soft:  rgb(26 24 23 / 0.09);
  --gold:       #b8873f;   /* decoration, icons, rules */
  --gold-ink:   #8a6520;   /* 5.1:1 on paper, safe for text */
  --blush:      #e3c7c1;
  --error:      #9d5a4e;
  --field:      transparent;   /* inputs are a single rule, not a box */

  /* Photography only. The illustrations keep their gold. */
  --photo-filter: grayscale(0.92) contrast(1.05) brightness(1.02);

  --wrap: 1200px;
  --gutter: clamp(1.25rem, 5vw, 4rem);
  --pad-y: clamp(4.5rem, 9vw, 8rem);
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --z-nav: 40;
}

/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-weight: 300;
  font-size: 0.9375rem;
  line-height: 1.9;
  /* Nunito is wider and rounder than Jost, so it needs far less tracking. */
  letter-spacing: 0.02em;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img { display: block; max-width: 100%; height: auto; }
ul, ol { list-style: none; padding: 0; }
button { font: inherit; color: inherit; }
a { color: inherit; }
fieldset { border: 0; padding: 0; min-inline-size: 0; }
[hidden] { display: none !important; }

:focus-visible { outline: 1px solid var(--gold-ink); outline-offset: 4px; }
::selection { background: var(--blush); color: #1a1817; }

/* ---------- Utilities ---------- */
.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: var(--gutter); }
.wrap--narrow { max-width: 720px; }
.u-center { text-align: center; margin-inline: auto; }
.u-visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.section--alt { background: var(--paper-alt); }

.rule { display: block; width: 90px; height: 1px; background: var(--line); }

/* ---------- Icons ---------- */
/* The plates live in a <symbol> sprite at the top of index.html. `use` renders
   them into a shadow tree, so nothing in this file can select the shapes inside
   an icon — only INHERITED properties cross that boundary. That is deliberate:
   stroke, fill and stroke-width are inherited here and are the entire styling
   surface, which is why the sprite carries its ornament weights as attributes.

   Weight is fixed, not scaled with the icon. An engraved line is cut at one
   depth whatever the size of the plate, and letting it thin out at 30px is what
   makes line icons look cheap. */
.icon {
  display: block;
  flex: none;
  width: 1.5rem; height: 1.5rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.15;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* Inline with a label rather than sitting on its own: match the text. */
.btn .icon, .guest__remove .icon { width: 1.75em; height: 1.75em; }

.skip-link {
  position: absolute; top: 0; left: 50%; translate: -50% -140%;
  z-index: 60; background: var(--ink); color: var(--paper);
  padding: 0.7rem 1.5rem; border-radius: 0 0 999px 999px;
  font-size: 0.6875rem; letter-spacing: 0.2em; text-transform: uppercase;
  text-decoration: none; transition: translate 0.25s var(--ease);
}
.skip-link:focus-visible { translate: -50% 0; }

/* ---------- Type ---------- */

/* Cardo italic carries the section headings. It has a normal x-height, unlike a
   script, so every size below is roughly half what the script needed. */
.script {
  font-family: var(--display);
  font-style: italic;
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: 0.005em;
  padding-bottom: 0.08em;   /* italic descender clearance */
}
.script--lg    { font-size: clamp(2rem, 1.2rem + 3.2vw, 3.5rem); }
.script--md    { font-size: clamp(1.625rem, 1.15rem + 2vw, 2.5rem); }
.script--sm    { font-size: clamp(1.5rem, 1.15rem + 1.5vw, 2.125rem); }
.script--xs    { font-size: clamp(1.125rem, 1rem + 0.5vw, 1.375rem); color: var(--ink-2); }
.script__amp   { margin-inline: 0.14em; }

.caps {
  font-family: var(--sans);
  font-weight: 300;
  font-size: 0.6875rem;
  line-height: 1.8;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.body { color: var(--ink-2); max-width: 62ch; }
.u-center.body, .u-center > .body { margin-inline: auto; }

/* ---------- The monogram mark ----------
   monogram.webp is ink strokes on a real alpha channel, so it needs no blend
   modes. Inverted it becomes white foil for use over photography. */
.mark { display: block; }
.mark img { display: block; width: 100%; height: auto; }

.mark--hero   { width: clamp(190px, 26vw, 400px); }
.mark--band   { width: clamp(110px, 15vw, 210px); }
.mark--footer { width: clamp(92px, 11vw, 128px); }

.mark--hero img, .mark--band img {
  filter: invert(1) drop-shadow(0 2px 18px rgb(0 0 0 / 0.45));
  opacity: 0.95;
}
.mark--footer img { opacity: 0.85; }

/* ---------- Scroll reveal ---------- */
[data-reveal] {
  opacity: 0; transform: translateY(18px);
  transition: opacity 1s var(--ease), transform 1s var(--ease);
}
[data-reveal].is-in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
  [data-reveal] { opacity: 1 !important; transform: none !important; transition: none; }
}

/* ---------- Photography vs artwork ----------
   Photographs are pulled almost to monochrome so they sit under the gold.
   The watercolour plates keep their colour and are never cropped. */
.band > img, .photo-break img, .hero__media img { filter: var(--photo-filter); }

.plate {
  background: var(--plate-bg);
  display: grid;
  place-items: center;
  padding: var(--plate-pad);
}
.plate img {
  width: 100%;
  height: auto;
  max-height: 78vh;
  object-fit: contain;
}

/* =============================================================================
   INTRO: the envelope
   Press the embossed monogram on the flap. The camera pulls back and the
   envelope becomes a lit object on a dark stage, the flap folds back, the
   invitation rises out and opens into a full-screen letter, and it stops there
   until the guest asks for the site. Every visit.

   THE ONE RULE: .rig, .env and .env__flap carry transform-style: preserve-3d,
   and filter / clip-path / mask / opacity<1 / mix-blend-mode all silently force
   that back to flat. Never put any of those on those three. Fade the leaves.
   ========================================================================== */
html.intro, html.intro body { overflow: hidden; height: 100%; }

/* --- what the hero looks like while the intro owns it --------------------
   These are static property overrides, not animations, so it is safe for them
   to stop applying when `.intro-done` lands at release: an opacity rule ceasing
   to apply simply yields to the base value. There is no restart semantics here,
   which is exactly why the `animation` rules further down need `has-intro`
   instead. It looks like the same trap and is not. */
html.has-intro:not(.intro-done) .hero__inner,
html.has-intro:not(.intro-done) .nav { opacity: 0; }
html.has-intro:not(.intro-done) .hero__media img { transform: scale(1.07); }
/* White foil needs a dark ground even before hero-toast.webp has decoded. */
html.has-intro .hero { background: #1f1c1a; }

/* A guest printing the invitation must not get a blank sheet. */
@media print {
  #curtain { display: none !important; }
  html.intro, html.intro body { overflow: visible; height: auto; }
}

#curtain {
  position: fixed; inset: 0; z-index: 100;
  cursor: pointer;

  /* The envelope's fixed coordinate space. Declared here rather than on .env
     because .rig has to be given the same explicit size — see below. */
  --env-w: 1160px;
  --env-h: 800px;
  /* `overflow: hidden` alone does not hold iOS Safari. With an indefinite wait
     for a click, a guest would rubber-band the page behind a fixed curtain. */
  overscroll-behavior: contain;
  touch-action: none;

  /* --- the stock ---------------------------------------------------------
     Heavy cotton rag, warmer and deeper than the page it covers. The envelope
     is its own object; it does not need to match the site's paper. */
  --stock:        #faf1de;
  --stock-flap:   #fdf6e6;
  /* A lined envelope, in the border's pink. Rotated to hue 342 to match, with
     saturation and lightness left exactly where they were: the lining reads as
     a material lit from above, and moving anything but the hue would have
     flattened the shading it shares with --stock-lining-deep and -pale. */
  --stock-lining:      #e3c1cb;
  --stock-lining-pale: #f4dee5;   /* the lit top edge of the fold */
  --stock-lining-deep: #c98499;   /* and the shadowed bottom of it */

  /* Two noise layers make the difference between "textured" and "heavy".
     TOOTH is the fibre you feel; MOTTLE is the cloudy density variation that
     only handmade and high-GSM stock has.

     feTurbulence alone is nearly invisible over a light ground, because its own
     alpha channel is noise too and largely cancels itself out. The colour matrix
     throws the source RGB away, sets a flat colour through the offset column,
     and maps the red channel into alpha. Crucially that colour is a warm brown,
     not black: black speckle desaturates the stock and turns ivory into grey
     card, which is the opposite of expensive. */
  --tooth: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='t' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.55' numOctaves='5' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0.36 0 0 0 0 0.24 0 0 0 0 0.10 0.11 0 0 0 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23t)'/%3E%3C/svg%3E");

  --mottle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='700' height='700'%3E%3Cfilter id='m' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.006' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0 0 0 0 0.40 0 0 0 0 0.28 0 0 0 0 0.12 0.05 0 0 0 0'/%3E%3C/filter%3E%3Crect width='700' height='700' filter='url(%23m)'/%3E%3C/svg%3E");

  /* corners fall off, so the paper reads as lit rather than filled */
  --vignette: radial-gradient(125% 105% at 50% 38%, transparent 52%, rgb(96 62 28 / 0.13) 100%);
}

/* --- the stage ----------------------------------------------------------
   Revealed as the camera pulls back. The envelope stops being a surface that
   fills the screen and becomes a lit object sitting on a dark ground. */
.stage {
  position: absolute; inset: 0;
  opacity: 0;
  /* A pool of light for the envelope to sit IN. Without it the ground under the
     envelope is already near black and the contact shadow has nothing to read
     against, so the object never lands on anything. */
  background:
    radial-gradient(92% 78% at 50% 47%, #3c342c 0%, #241f1a 46%, #100e0b 100%);
}

/* Flat, and ABOVE the perspective. Exists only for the handoff: a transform on
   an ancestor of a perspective element maps the projected result uniformly, so
   aligning the card's monogram to the hero's is two lines of arithmetic. */
.aim { position: fixed; inset: 0; z-index: 2; }

.scene {
  position: fixed; inset: 0;
  perspective: 2200px;
}

/* The camera. Centred with negative margins rather than flex or grid alignment,
   and that is deliberate: at cover scale the rig is WIDER than the viewport, and
   Chrome clamps `justify-items: center` to `start` when an item overflows its
   container. On a 390px phone that pinned the envelope to the left edge and put
   the seal off-screen. Negative margins centre it unconditionally, and they
   leave `transform` entirely to GSAP with no baseline to compose against.
   The explicit size also makes the rig the containing block for the contact
   shadow, so that measures against the envelope rather than the viewport. */
.rig {
  position: absolute;
  left: 50%; top: 50%;
  width: var(--env-w); height: var(--env-h);
  margin-left: calc(var(--env-w) / -2);
  margin-top: calc(var(--env-h) / -2);
  transform-style: preserve-3d;
}

/* --- the envelope --------------------------------------------------------
   A FIXED box, not the viewport. Every proportion inside it (flap 48%, V apex
   46%, seal size) is now relative to this, and stays meaningful at any scale.
   No vw/vh may appear below this line. */
.env {
  position: absolute; inset: 0;
  transform-style: preserve-3d;
}

/* Depth is real, not painted. Between preserve-3d siblings the paint order is
   geometric and z-index is honoured inconsistently across engines, so every
   layer gets a translateZ instead. */
/* What the opened flap reveals. Deliberately the LETTER's stock, not a tan
   envelope interior: the sheet grows out of this exact region a moment later, so
   making them the same material means the opening reads as the letter inside
   rather than as an empty envelope that then gets covered up. The gradient at the
   top is the shadow the flap was casting. */
.env__back {
  position: absolute; inset: 0;
  transform: translateZ(-14px);
  background-image:
    var(--tooth),
    linear-gradient(180deg, rgb(58 42 18 / 0.26) 0%, rgb(58 42 18 / 0.07) 20%, transparent 44%),
    linear-gradient(178deg, #fffaf0 0%, #fbf4e6 60%, #f7eeda 100%);
  background-size: 180px 180px, 100% 100%, 100% 100%;
}

/* Contact shadow. Softness from a gradient, never `filter: blur()` — a filter
   here would flatten the 3D context it sits in. */
.env__shadow {
  position: absolute;
  /* Sits mostly BELOW the envelope: anything overlapping the silhouette is
     hidden behind it and does no work. */
  left: 50%; top: 88%;
  width: 132%; height: 30%;
  translate: -50% 0;
  transform: translateZ(-40px);
  opacity: 0;
  background: radial-gradient(closest-side, rgb(0 0 0 / 0.78), rgb(0 0 0 / 0) 100%);
}

.env__pocket {
  position: absolute; inset: 0;
  transform: translateZ(0px);
  clip-path: polygon(0 0, 50% 46%, 100% 0, 100% 100%, 0 100%);
  background-image: var(--mottle), var(--tooth), var(--vignette),
                    linear-gradient(var(--stock), var(--stock));
  background-size: 700px 700px, 180px 180px, 100% 100%, 100% 100%;
}

/* The shadow the lifting flap throws down into the fold. Clipped to the pocket,
   so it never falls across the opening. */
.env__shade {
  position: absolute; inset: 0;
  transform: translateZ(1px);
  /* A closed flap still rests ON the panel, so there is a contact shadow from
     the very first frame. Starting at zero makes the fold look printed on. */
  opacity: 0.45;
  clip-path: polygon(0 0, 50% 46%, 100% 0, 100% 100%, 0 100%);
}
/* A blurred copy of the flap, nudged down. The parent clip removes everything
   inside the opening, so all that survives is the soft fringe hugging the two
   diagonals: a shadow that follows the fold instead of a round pool. */
.env__cast {
  position: absolute; top: 0; left: 0;
  width: 100%; height: 48%;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  /* Heavier stock sits proud of the panel, so it throws a wider, deeper shadow. */
  background: rgb(66 44 20 / 0.5);
  filter: blur(28px);
  transform: translateY(15px);
}

/* Fills the V, hinged on the envelope's top edge. Carries no clip-path, filter
   or opacity of its own: any of those would flatten the 3D context and collapse
   the two faces into one. The same prohibition applies to .rig and .env — to
   fade the envelope, fade its leaves individually, never these three. */
.env__flap {
  position: absolute; top: 0; left: 0;
  width: 100%; height: 48%;
  transform-origin: 50% 0;
  transform-style: preserve-3d;
  /* Its translateZ is set from JS, not here: GSAP rewrites the whole transform
     when it tweens rotationX, so animated layers get their Z through gsap.set. */
}
.env__face, .env__lining {
  position: absolute; inset: 0;
  backface-visibility: hidden;
}

/* outside of the flap: catches the light, and the crease along the fold */
.env__face {
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  background-image:
    var(--mottle), var(--tooth),
    /* the fold catches the light along its full length */
    linear-gradient(180deg, rgb(255 255 255 / 0.85) 0 2px, transparent 2px),
    var(--vignette),
    /* Only just lighter than the panel. Same paper, different angle to the
       light: the fold is read from the shadow, not from a colour change. */
    linear-gradient(180deg, var(--stock-flap) 0%, #fbf3e2 55%, #f8efdb 100%);
  background-size: 700px 700px, 180px 180px, 100% 100%, 100% 100%, 100% 100%;
}

/* The liner, showing as a fine line along the flap edge, which is how a lined
   envelope reads from the front. It doubles as the thickness of the stock.
   NOTE: this is where the liner has to live. With the hinge on the very top edge
   of the screen, every angle past 90 degrees puts the flap above the viewport,
   so its actual underside is never visible. .env__lining below is kept correct
   but is effectively never seen. */
.env__edge {
  position: absolute; inset: 0;
  backface-visibility: hidden;
  clip-path: polygon(0 0, 100% 0, 50% 100%);
  /* The liner is a slightly LARGER copy of the flap sitting behind it, so this
     scale is the line's thickness. It is not a border: the flap is clipped to a
     triangle, and a clipped element cannot carry one. */
  transform: scale(1.013);
  /* The gradient's END is what shows at the fold, so --stock-lining-deep is the
     stop that actually carries the colour here. At the mid tone the line reads
     washed out against cream: too little separation at 2px to register as pink
     at all. */
  background: linear-gradient(180deg, var(--stock-lining-pale),
                                      var(--stock-lining) 38%,
                                      var(--stock-lining-deep));
}

/* inside of the flap: unlit, so it reads as the envelope's lining. Its clip is
   mirrored, because the element is flipped before the flap itself rotates. */
.env__lining {
  clip-path: polygon(0 100%, 100% 100%, 50% 0);
  transform: rotateX(180deg);
  background-image:
    var(--tooth),
    linear-gradient(0deg, rgb(0 0 0 / 0.22) 0%, transparent 55%),
    linear-gradient(180deg, var(--stock-lining-pale) 0%,
                            var(--stock-lining) 46%,
                            var(--stock-lining-deep) 100%);
  background-size: 180px 180px, 100% 100%, 100% 100%;
}

/* =============================================================================
   THE INVITATION
   Written mobile-first, because that is where essentially all of this will be
   read. The desktop rules only widen the measure and lift the scale.
   ========================================================================== */
.letter {
  position: fixed; inset: 0;
  /* BELOW .aim. The letter is uncovered by the envelope dissolving off it, not
     revealed on top of it, so it has to sit underneath from the first frame. */
  z-index: 1;
  display: grid;
  grid-template-rows: 1fr auto;
  background-image: var(--mottle), var(--tooth), var(--vignette),
                    linear-gradient(178deg, #fffaf0 0%, #fbf4e6 60%, #f7eeda 100%);
  background-size: 700px 700px, 180px 180px, 100% 100%, 100% 100%;

  /* vmin, not a percentage: `inset: 3%` measures top and bottom against the
     height and the sides against the width, so on a 1920x1080 screen the
     border would sit 58px in at the sides and 32px at the top. A frame has to
     hold the same gap the whole way round or it reads as a mistake. */
  --frame-inset: clamp(15px, 3vmin, 40px);
  /* READ THE SECOND COLOUR, NOT THE FIRST. This is 62% ink on a warm cream
     sheet (~#f8f0de), and the paper's yellow drags every blend back toward
     peach. The two previous attempts were picked as swatches and both landed
     salmon on the page for exactly that reason.

     So the value below is solved backwards from the result. Target composite
     #e1b0bd — hue 342, S 0.44, L 0.79, a neutral soft pink — then the paper is
     divided back out, which is why the raw colour has to sit further round at
     hue 334 than the border ever appears. In isolation it looks too pink; at
     62% over this cream it is not.

     Neutral is the brief, so 342 is the target: far enough off 0 that no red
     warmth reads as peach, well short of 320 where it would start to go cool
     and mauve. Lightness stays up at 0.79 to keep it in the baby-pink family.

     The envelope's lining is rotated to the same hue — see --stock-lining. It
     is opaque there and translucent here, so the two carry different hex values
     to arrive at the same pink. */
  --frame-rule: rgb(211 137 169 / 0.62);

  /* Heaviest outside, thinnest inside, and the two gaps deliberately unequal:
     a wide step from the heavy rule to the middle one, then the hairline
     tucked close against it. Equal gaps would read as three of the same thing
     rather than as one border. All scale on vmin so the proportion holds from
     a 390px phone to a 2560px monitor. */
  --rule-a: clamp(2px, 0.42vmin, 3.5px);      /* outermost, thickest */
  --rule-b: clamp(1px, 0.2vmin, 1.75px);
  --rule-c: 1px;                              /* innermost hairline */
  --gap-ab: clamp(9px, 1.8vmin, 17px);        /* a good step */
  --gap-bc: clamp(3px, 0.62vmin, 6px);        /* much closer */
}

/* --- the printed border ---------------------------------------------------
   Three concentric rules, square corners, heaviest on the outside. Nested
   rather than three siblings: each rule is inset from the PADDING BOX of the
   one outside it, so every gap below is the gap you actually see, and changing
   a weight cannot silently move the rules inside it.

   Absolutely positioned because .letter is a two-row grid — an in-flow child
   here would become a third row and push the cue off the bottom. */
.letter__frame {
  position: absolute;
  inset: var(--frame-inset);
  pointer-events: none;
  border: var(--rule-a) solid var(--frame-rule);
}

/* the middle rule: a clear step in from the heavy one */
.letter__frame::before {
  content: "";
  position: absolute;
  inset: var(--gap-ab);
  border: var(--rule-b) solid var(--frame-rule);
}

/* and the hairline, tucked close against it */
.letter__frame::after {
  content: "";
  position: absolute;
  inset: calc(var(--gap-ab) + var(--rule-b) + var(--gap-bc));
  border: var(--rule-c) solid var(--frame-rule);
}

/* The monogram is anchored to the top, not centred. --mark-y is computed in
   layout() so it lands on exactly the pixels the flap's embossed one occupied;
   the text then takes all the room left underneath.

   Two rows: the mark at its fixed offset, then 1fr. Stretching to fill the
   row (rather than align-self: start) is what gives that 1fr something to
   measure against. */
.letter__inner {
  display: grid;
  grid-template-rows: auto 1fr;
  justify-items: center;
  text-align: center;
  gap: clamp(1.1rem, 4vw, 1.6rem);
  /* The side padding clears the border rather than the screen edge: a long
     guest name is the widest thing on the sheet, and at the old flat value it
     would have run into the innermost rule on a phone. */
  padding: var(--mark-y, 16vh) calc(var(--frame-inset) + clamp(1.5rem, 7vw, 2.75rem)) 3vh;
  color: var(--ink);
}

/* Positioned from the top, and layout() then sets padding-top so the block sits
   on the VIEWPORT's midpoint. It is not centred in this box: the box is the room
   left under the monogram, whose middle is a long way below the screen's, which
   is what read as sitting too low. See the note in layout(). */
.letter__text {
  display: grid;
  align-content: start;
  justify-items: center;
  gap: clamp(1.1rem, 4vw, 1.6rem);
  /* Only ever seen if the measurement does not run. */
  padding-top: clamp(1rem, 4vh, 2.5rem);
}

/* Visible from the first frame, behind the flap. Everything else waits. */
.letter__mark { width: var(--mark-px, 100px); height: auto; opacity: 0.9; }
.letter__salutation, .letter__body { opacity: 0; }

.letter__salutation {
  font-family: var(--display); font-style: italic;
  font-size: clamp(1.4rem, 6.4vw, 2rem);
  line-height: 1.25;
  padding-bottom: 0.08em;
  /* The ink. A feathered mask whose edge the open sequence sweeps across the
     line, so the name is written rather than faded — and the element stays
     real text, which is what lets ?to= names cost nothing. The default parks
     the edge past the right end: if the tween never runs, the text is simply
     there the moment opacity arrives. Just past vertical (100deg), because a
     pen's wet edge is not plumb. */
  --ink-p: 118%;
  -webkit-mask-image: linear-gradient(100deg,
      #000 calc(var(--ink-p) - 15%), transparent var(--ink-p));
  mask-image: linear-gradient(100deg,
      #000 calc(var(--ink-p) - 15%), transparent var(--ink-p));
}

.letter__body {
  font-family: var(--sans); font-weight: 300;
  font-size: clamp(0.9375rem, 4.1vw, 1.0625rem);
  line-height: 1.85;
  letter-spacing: 0.02em;
  color: var(--ink-2);
  max-width: 30ch;
  /* The body is not written — a paragraph writing itself turns reading into
     waiting. It settles top-down instead, behind a feather deep enough that
     the first line is legible while the last is still arriving, which reads
     as line-by-line without the text being split. Same safe default. */
  --ink-pv: 135%;
  -webkit-mask-image: linear-gradient(180deg,
      #000 calc(var(--ink-pv) - 30%), transparent var(--ink-pv));
  mask-image: linear-gradient(180deg,
      #000 calc(var(--ink-pv) - 30%), transparent var(--ink-pv));
}

/* The couple's names, the date and the venue used to sit here, under the body.
   They now live only in the page proper — the invitation is the greeting, the
   page is the detail. Their rules are deleted rather than left dormant so that
   restoring them is a deliberate act, not a class name that silently works. */

/* The gate out of the invitation and into the site. A real button, so it is
   reachable by keyboard, but any swipe, scroll or key works too. */
.letter__cue {
  opacity: 0;
  justify-self: center;
  display: grid; justify-items: center; gap: 1.2rem;
  /* clear of the home indicator on a notched phone */
  padding: 0 1.5rem calc(4.5vh + env(safe-area-inset-bottom));
  background: none; border: 0; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.letter__cue-text {
  font-family: var(--sans); font-weight: 300;
  font-size: 0.6875rem;
  letter-spacing: 0.28em; text-transform: uppercase; text-indent: 0.28em;
  color: var(--ink-3);
}
/* Two strokes rather than an icon glyph: the icon font is not loaded this early. */
.letter__cue-arrow {
  display: block; width: 15px; height: 15px;
  border-left: 1px solid var(--gold); border-top: 1px solid var(--gold);
  rotate: 45deg;
}
/* arrive() in main.js focuses this button the instant the invitation lands, so
   that a keyboard guest is standing on the gate. Safari scores a scripted
   .focus() as :focus-visible whatever the guest last touched, which drew both
   rings on every phone: the global one round the button — a wide box with
   4.5vh of bottom padding, so it ran off the screen — and this one round the
   words. The button's own ring goes for good, since the text is the thing to
   mark, and what is left waits for .kbd, which main.js sets on the first real
   key press and never on a tap. */
.letter__cue:focus-visible { outline: none; }
.kbd .letter__cue:focus-visible .letter__cue-text {
  outline: 1px solid var(--gold-ink); outline-offset: 6px;
}

/* And the same rule for the whole curtain rather than the cue alone, because
   the cue is only the element we know moves focus by script — the rest of the
   intro is a full-screen picture where a stray ring is just as wrong, and this
   is one line against having to find each one twice. Outranks both the global
   :focus-visible and anything scoped to a class inside here (1-2-1). */
html:not(.kbd) #curtain :focus-visible { outline: none; }

@media (prefers-reduced-motion: no-preference) {
  .letter__cue-arrow { animation: cue-rise 2.4s var(--ease) infinite; }
}
@keyframes cue-rise {
  0%, 100% { transform: translateY(3px); opacity: 0.55; }
  50%      { transform: translateY(-3px); opacity: 1; }
}

/* --- the monogram, blind embossed into the flap --------------------------
   No ink. The mask cuts the monogram's shape out of a paper-toned fill, so the
   glyph IS the stock, and the whole impression is carried by two drop-shadows:
   a white one up-left where a raised edge catches the light, and a warm dark one
   down-right where it falls away. That is what blind embossing actually is, and
   it is why this must not be an <img> with the ink showing through. */
.flapmark {
  /* Both of these are set from layout() in main.js, not here. The flap is a
     triangle whose top is off-screen by a different amount at every aspect
     ratio, so position and width have to be solved together at runtime. The
     fallbacks are the phone case. */
  position: absolute;
  left: 50%; top: var(--mark-top, 21.6%);
  translate: -50% 0;
  width: var(--mark-w, 17.6%);
  aspect-ratio: 765 / 716;
  padding: 0; border: 0; background: none;
  cursor: pointer;
  backface-visibility: hidden;
  -webkit-tap-highlight-color: transparent;
}
.flapmark::before {
  content: "";
  position: absolute; inset: 0;
  -webkit-mask: url("monogram.webp") center / contain no-repeat;
  mask: url("monogram.webp") center / contain no-repeat;
  /* Matched to the flap's own tone at this height, NOT lighter. A lighter fill
     reads as a white monogram printed on the paper; the whole point of a blind
     emboss is that the glyph IS the paper and only the bevel gives it away. */
  background: linear-gradient(158deg, #faf4e7 0%, #fdf9f0 50%, #f8f1e2 100%);
  /* The bevel scales WITH the mark. Fixed pixel offsets read fine at 200px and
     vanish at 100px, because the strokes get thinner while the shadows do not. */
  --emb: calc(var(--mark-px, 130px) / 62);
  filter:
    drop-shadow(calc(var(--emb) * -0.8) calc(var(--emb) * -1.15) calc(var(--emb) * 0.3)
                rgb(255 255 255 / 0.95))
    drop-shadow(calc(var(--emb) * 1.1) calc(var(--emb) * 1.65) calc(var(--emb) * 1.1)
                rgb(104 78 38 / 0.6));
  transition: transform 0.4s var(--ease), filter 0.4s var(--ease);
}
.flapmark:hover::before {
  transform: scale(1.03);
  filter:
    drop-shadow(calc(var(--emb) * -0.9) calc(var(--emb) * -1.5) 0 rgb(255 255 255 / 1))
    drop-shadow(calc(var(--emb) * 1.5) calc(var(--emb) * 2.2) calc(var(--emb) * 1.4)
                rgb(104 78 38 / 0.7));
}
/* Pressed IN, which is the natural thing for a raised impression under a thumb. */
.flapmark:active::before {
  transform: scale(0.99);
  transition-duration: 0.09s;
  filter:
    drop-shadow(calc(var(--emb) * 0.8) calc(var(--emb) * 1.15) 0 rgb(255 255 255 / 0.8))
    drop-shadow(calc(var(--emb) * -0.8) calc(var(--emb) * -1.15) calc(var(--emb) * 0.9)
                rgb(104 78 38 / 0.55));
}
.flapmark:focus-visible { outline: none; }
.flapmark:focus-visible::before {
  filter:
    drop-shadow(calc(var(--emb) * -0.8) calc(var(--emb) * -1.15) 0 rgb(255 255 255 / 0.95))
    drop-shadow(calc(var(--emb) * 1.1) calc(var(--emb) * 1.65) calc(var(--emb) * 1.1)
                rgb(104 78 38 / 0.6))
    drop-shadow(0 0 6px rgb(138 101 32 / 0.9));
}
@media (hover: none) { .flapmark:hover::before { transform: none; } }

/* --- the prompt ----------------------------------------------------------
   Direct child of #curtain, so it lives in viewport space and does not scale
   with the camera. */
.intro__prompt {
  position: absolute;
  z-index: 3;
  left: 50%; bottom: 11vh;
  translate: -50% 0;
  opacity: 0;
  pointer-events: none;
  font-family: var(--sans); font-weight: 400;
  /* Big enough to be read as an instruction rather than a caption. Clamped
     rather than fixed because the tracking makes the line long: on a 360px
     phone "Open the invitation" at the top of this range would touch both
     edges, and the line must not wrap. */
  font-size: clamp(0.8125rem, 0.62rem + 0.85vw, 0.9375rem);
  letter-spacing: 0.3em; text-transform: uppercase; text-indent: 0.3em;
  color: #61523c;
  text-shadow: 0 1px 0 rgb(255 255 255 / 0.4);
  white-space: nowrap;
}

/* =============================================================================
   Buttons  (delicate outlined ovals, as on the stationery)
   ========================================================================== */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
  white-space: nowrap;
  font-family: var(--sans); font-weight: 300;
  font-size: 0.6875rem; letter-spacing: 0.28em; text-transform: uppercase;
  text-indent: 0.28em;
  text-decoration: none; cursor: pointer;
  color: var(--ink);
  background: transparent;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.95rem 2.4rem;
  transition: background-color 0.45s var(--ease), color 0.45s var(--ease),
              border-color 0.45s var(--ease), transform 0.12s ease;
}
.btn:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.btn:active { transform: translateY(1px); }

.btn--solid { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.btn--solid:hover { background: var(--gold-ink); border-color: var(--gold-ink); color: #fff; }

.btn--light { color: #fff; border-color: rgb(255 255 255 / 0.45); }
.btn--light:hover { background: #fff; color: var(--ink); border-color: #fff; }

.btn--quiet {
  border-color: var(--line-soft); color: var(--ink-3);
  padding: 0.75rem 1.75rem; letter-spacing: 0.2em; text-indent: 0.2em;
}
.btn--quiet:hover { background: transparent; color: var(--ink); border-color: var(--line); }

.btn[disabled] { opacity: 0.45; cursor: not-allowed; transform: none; }

/* =============================================================================
   Navigation  (bare, centred, hairline beneath. No pill, no card.)
   ========================================================================== */
#nav-sentinel { position: absolute; top: 0; left: 0; width: 1px; height: calc(100dvh - 90px); }

.nav {
  position: fixed; top: 0; left: 0; right: 0;
  z-index: var(--z-nav);
  display: flex; justify-content: center;
  padding: clamp(1.5rem, 3vw, 2.25rem) var(--gutter);
  color: #fff;
  transition: background-color 0.5s var(--ease), color 0.5s var(--ease),
              padding 0.5s var(--ease), box-shadow 0.5s var(--ease);
}
.nav.is-scrolled {
  color: var(--ink);
  background: var(--paper);
  background: color-mix(in srgb, var(--paper) 94%, transparent);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  padding-block: clamp(0.85rem, 1.6vw, 1.1rem);
  box-shadow: 0 1px 0 var(--line-soft);
}

.nav__list {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  gap: clamp(1rem, 2.6vw, 2.5rem);
  padding-bottom: 0.85rem;
}
/* One continuous hairline under the whole row, not a dash per item. */
.nav__list::after {
  content: "";
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 1px; background: currentColor; opacity: 0.4;
}
.nav__list a {
  display: block;
  font-size: 0.625rem; font-weight: 300;
  letter-spacing: 0.28em; text-transform: uppercase; text-indent: 0.28em;
  text-decoration: none; white-space: nowrap;
  opacity: 0.8;
  transition: opacity 0.35s var(--ease);
}
.nav__list a:hover { opacity: 1; }
.nav__toggle { display: none; }

@media (max-width: 899px) {
  .nav { justify-content: flex-end; padding-block: 1rem; }
  .nav.is-scrolled { padding-block: 1rem; }

  .nav__toggle {
    display: grid; place-items: center;
    width: 44px; height: 44px;
    background: none; border: 0; cursor: pointer; border-radius: 999px;
    /* MUST stay positioned. .nav__menu below is fixed inset:0 with an opaque
       background, and a positioned element always paints over a static one no
       matter which came first in the source. Without this the button — and the
       X it turns into — is buried under the open menu, and the only ways out
       are Escape or picking a link. */
    position: relative;
    z-index: 1;
  }
  .nav__bars { display: block; width: 22px; }
  .nav__bars span {
    display: block; height: 1px; background: currentColor;
    transition: transform 0.4s var(--ease);
  }
  .nav__bars span:first-child { margin-bottom: 6px; }
  .nav.is-open .nav__bars span:first-child { transform: translateY(3.5px) rotate(45deg); }
  .nav.is-open .nav__bars span:last-child  { transform: translateY(-3.5px) rotate(-45deg); }

  .nav__menu {
    position: fixed; inset: 0;
    display: grid; place-items: center;
    background: var(--paper); color: var(--ink);
    opacity: 0; visibility: hidden;
    transition: opacity 0.45s var(--ease), visibility 0.45s;
  }
  .nav.is-open .nav__menu { opacity: 1; visibility: visible; }
  .nav.is-open { color: var(--ink); }

  .nav__list { flex-direction: column; gap: 0; text-align: center; padding-bottom: 0; }
  .nav__list::after { display: none; }
  .nav__list a { font-size: 0.8125rem; padding: 1.1rem 2rem; opacity: 1; }
}

/* =============================================================================
   Hero
   ========================================================================== */
.hero {
  position: relative; min-height: 100dvh;
  display: grid; place-items: center;
  overflow: hidden; isolation: isolate;
}
.hero__media { position: absolute; inset: 0; z-index: -2; }
/* A PORTRAIT source in a landscape frame, so the bias that matters is vertical,
   not horizontal: on a wide desktop this is cropped to a band about a third of
   the photo's height. 42% puts the glasses and hands in that band — dead centre
   would take the rims off the top. */
.hero__media img { width: 100%; height: 100%; object-fit: cover; object-position: center 42%; }
/* A phone is close to the source's own 2:3, so barely any crop happens and the
   framing can sit where the photograph actually composed it. */
@media (max-width: 767px) { .hero__media img { object-position: center 48%; } }

@media (prefers-reduced-motion: no-preference) {
  /* One slow settle on load, then still. */
  /* Only when the intro did NOT run. On the intro path GSAP drives the settle
     instead, so the guest actually sees it rather than it finishing behind a
     curtain. Note this is gated on `has-intro`, which is permanent, NOT on
     `intro`, which is removed at release: `html:not(.intro)` would START the
     animation at the moment of release. */
  html:not(.has-intro) .hero__media img { animation: settle 10s var(--ease) both; }
}
@keyframes settle { from { transform: scale(1.07); } to { transform: scale(1); } }

/* --- the scroll hint ------------------------------------------------------
   Faint by design: it only has to be found by someone already wondering
   whether the page continues. Two borders on a rotated box, the same chevron
   the invitation uses, turned to point down.

   Inside .hero rather than fixed, so it simply leaves with the hero on the
   first scroll and never needs dismissing. */
.hero__cue {
  position: absolute;
  left: 50%;
  bottom: calc(clamp(1.5rem, 4vh, 2.75rem) + env(safe-area-inset-bottom));
  translate: -50% 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.34;
  /* Held back until the invitation has actually gone, then eased up on its
     own. The delay lets the monogram land first. */
  transition: opacity 1.2s var(--ease) 0.55s;
}
html.has-intro:not(.intro-done) .hero__cue { opacity: 0; }

/* The chevron is a child, not the cue itself, purely so the bob below and the
   fade above never share a property. */
.hero__cue span {
  display: block; width: 15px; height: 15px;
  border-right: 1px solid #fff; border-bottom: 1px solid #fff;
  rotate: 45deg;
}

@media (prefers-reduced-motion: no-preference) {
  .hero__cue span { animation: hero-cue 3.4s var(--ease) infinite; }
}
/* `translate`, not a transform: individual properties apply translate BEFORE
   rotate, so this moves straight down the screen. A translateY inside
   `transform` would land after the 45deg and drift the arrow diagonally. */
@keyframes hero-cue {
  0%, 100% { translate: 0 -3px; }
  50%      { translate: 0 3px; }
}

/* The photograph is already dark, so this only steadies the nav and the foot. */
.hero__scrim {
  position: absolute; inset: 0; z-index: -1;
  background: linear-gradient(180deg, rgb(0 0 0 / 0.34) 0%, rgb(0 0 0 / 0.04) 30%, rgb(0 0 0 / 0.34) 100%);
}

.hero__inner {
  display: grid; justify-items: center; gap: clamp(1.5rem, 3.5vw, 2.5rem);
  padding: 5.5rem var(--gutter) clamp(3rem, 8vw, 5rem);
  text-align: center;
  color: #fff;
}
.hero__inner .script { text-shadow: 0 2px 30px rgb(0 0 0 / 0.35); }

/* The mark stays centred in the viewport on mobile. The crop shifts left so it
   lands on the dark glass rather than on the couple, who stay to the right. */
@media (max-width: 767px) {
  .hero__inner { padding-block: 5rem; }
  .mark--hero { width: min(56vw, 240px); }
}

@media (prefers-reduced-motion: no-preference) {
  /* The monogram needs no rise on the intro path: it ARRIVES from the card,
     already in position. */
  html:not(.has-intro) .hero__inner > * { animation: rise 1.8s var(--ease) 0.25s both; }
}
@keyframes rise { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: none; } }

/* =============================================================================
   1. The Day
   ========================================================================== */
.day { padding-top: var(--pad-y); }
.day__inner {
  text-align: center;
  display: grid; justify-items: center; gap: clamp(0.75rem, 2vw, 1.25rem);
  padding-bottom: clamp(3.5rem, 8vw, 6rem);
}
.day__date {
  font-family: var(--serif);
  font-weight: 400;
  font-size: clamp(2.75rem, 1rem + 8.5vw, 7rem);
  line-height: 1;
  letter-spacing: 0.02em;
}
.day__date span { color: var(--gold); margin-inline: 0.06em; }

.plate--full { padding: calc(var(--plate-pad) + clamp(1rem, 3vw, 3rem)) var(--gutter); }
.plate--full img { max-width: 1080px; }

/* =============================================================================
   2. Travel
   ========================================================================== */
.travel { padding-block: var(--pad-y); }
.travel__grid { display: grid; grid-template-columns: 1fr; gap: clamp(2.5rem, 6vw, 4.5rem); align-items: center; }
@media (min-width: 900px) { .travel__grid { grid-template-columns: 1.02fr 0.98fr; } }

.travel .body { margin-top: 1.5rem; }
.travel__figure { align-self: center; }

.detail { margin-top: clamp(2rem, 4.5vw, 3rem); border-top: 1px solid var(--line-soft); }
.detail__row {
  display: grid; grid-template-columns: 1fr auto;
  align-items: start; gap: 0.35rem 1.5rem;
  padding-block: clamp(1.4rem, 3vw, 1.85rem);
}
.detail__row + .detail__row { border-top: 1px solid var(--line-soft); }
.detail__label { grid-column: 1 / -1; color: var(--gold-ink); margin-bottom: 0.35rem; }
.detail__venue { font-family: var(--serif); font-size: 1.375rem; line-height: 1.3; letter-spacing: 0.01em; }
.detail__addr { color: var(--ink-3); font-size: 0.8125rem; line-height: 1.7; }
.detail__time { font-family: var(--serif); font-size: 1.375rem; color: var(--ink-2); white-space: nowrap; letter-spacing: 0.01em; }

.detail__row--modes { grid-template-columns: 1fr 1fr; gap: clamp(1.25rem, 4vw, 2.5rem); }
.mode__icon { width: 2.75rem; height: 2.75rem; color: var(--gold); }
.mode__time { font-family: var(--serif); font-size: 1.25rem; margin-top: 0.5rem; letter-spacing: 0.01em; }
.mode__note { color: var(--ink-3); font-size: 0.8125rem; line-height: 1.7; }

/* =============================================================================
   Photo band (gifts)
   ========================================================================== */
.band { position: relative; display: grid; place-items: center; overflow: hidden; isolation: isolate; }
.band > img { position: absolute; inset: 0; z-index: -2; width: 100%; height: 100%; object-fit: cover; }
.band__scrim { position: absolute; inset: 0; z-index: -1; background: rgb(0 0 0 / 0.34); }
.band__scrim--deep { background: rgb(0 0 0 / 0.56); }

.band--text { padding-block: clamp(5rem, 11vw, 8.5rem); }

/* Fully black and white, against the site default of grayscale(0.92). That
   0.08 of remaining colour is deliberate elsewhere — it keeps skin tones from
   going lifeless — but under a scrim this deep it stops reading as warmth and
   starts reading as a cast. Set as an override of --photo-filter rather than
   a filter of its own so it goes through the same `.band > img` rule as every
   other band image and can't fall out of step with it. */
.band--mono { --photo-filter: grayscale(1) contrast(1.06) brightness(1.02); }
.band__inner { text-align: center; display: grid; justify-items: center; gap: 1.35rem; color: #fff; }
.band__inner .body { color: rgb(255 255 255 / 0.86); max-width: 52ch; }

/* The scrim is a flat wash, so it lifts the whole photograph evenly and can't
   help where the interior shot happens to be brightest — which is exactly
   where the type sits. Two layers rather than one: the tight shadow gives the
   glyph edges something to sit against, and the wide diffuse one acts as a
   small local scrim that travels with the text instead of the background.
   Kept low-opacity on purpose — enough to hold an edge, not enough to read as
   a drop shadow. The script face needs it most, its strokes being the
   thinnest thing on the page.

   Not on .btn: it carries its own solid surface and would only look embossed. */
.band__inner .script,
.band__inner .body {
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.38), 0 2px 14px rgb(0 0 0 / 0.32);
}

.band__inner .btn { margin-top: 0.75rem; }

/* =============================================================================
   4. Timeline  (two columns of small engraved rows)
   ========================================================================== */
.timeline { padding-block: var(--pad-y); }
.timeline .script { margin-bottom: clamp(2.5rem, 6vw, 4rem); }

.tl {
  display: grid; grid-template-columns: 1fr;
  gap: clamp(1.75rem, 3.5vw, 2.5rem) clamp(2rem, 6vw, 5rem);
  max-width: 900px; margin-inline: auto;
}
/* Column major, so each column reads top to bottom in time order. */
@media (min-width: 720px) {
  .tl { grid-template-columns: 1fr 1fr; grid-template-rows: repeat(5, auto); grid-auto-flow: column; }
}

.tl__item { display: grid; grid-template-columns: 58px auto 1fr; align-items: center; gap: 0 1rem; }
/* Pen-and-ink illustrations, not the line icons the rest of the page uses. They
   carry far more detail per shape, so they are set larger than an icon would be.

   The gold is baked into the files, not applied here. They arrived as black ink
   whose density lives in BOTH alpha and RGB darkness, so no CSS filter and no
   flat alpha recolour reproduces them — the drawings were composited to recover
   true per-pixel coverage and that coverage re-laid in --gold, with density
   raised about a third. Without the boost the hatching is too fine to survive
   --gold at this size and the whole drawing greys out. Re-tint at source if the
   colour changes; do not reach for filter().

   Each ships in a square 128px field with its subject already optically
   balanced inside, so one size here fits all nine and none need nudging. */
.tl__icon { width: 3.25rem; height: 3.25rem; object-fit: contain; }
.tl__time {
  font-family: var(--serif); font-size: 1.125rem; letter-spacing: 0.02em;
  white-space: nowrap; padding-right: 0.9rem; margin-right: 0.15rem;
  border-right: 1px solid var(--line-soft);
}
.tl__label { font-family: var(--sans); font-weight: 400; font-size: 0.875rem; letter-spacing: 0.14em; text-transform: uppercase; line-height: 1.5; }
.tl__note { font-family: var(--serif); font-style: italic; font-size: 0.875rem; letter-spacing: 0.01em; color: var(--ink-3); line-height: 1.5; padding-bottom: 0.08em; }

@media (max-width: 419px) {
  .tl__item { grid-template-columns: 48px 1fr; row-gap: 0.15rem; }
  .tl__icon { grid-row: 1 / span 2; width: 2.75rem; height: 2.75rem; }
  .tl__time { border-right: 0; padding-right: 0; }
}

/* =============================================================================
   5. Dress code
   ========================================================================== */
.dress { padding-block: var(--pad-y); }
.dress .script { margin-bottom: clamp(1.25rem, 2.5vw, 1.75rem); }

/* The label under the heading, so a guest who reads nothing else in this
   section still leaves knowing the actual name of the code — "dress for the
   occasion" isn't the thing anyone googles, "black tie" is. */
.dress__code { text-align: center; color: var(--gold-ink); margin-bottom: 1.5rem; }
.dress__lede { max-width: 54ch; margin-top: clamp(1.25rem, 2.5vw, 1.75rem); }

/* Split by garment, not by gender: "gowns" and "tuxedos" is how the code itself
   is written, and it avoids telling any given guest which half is theirs. */
.dress__cols {
  display: grid; gap: clamp(1.5rem, 4vw, 2rem);
  max-width: 820px; margin: clamp(2.25rem, 5vw, 3rem) auto 0;
  border-top: 1px solid var(--line-soft);
  padding-top: clamp(1.75rem, 4vw, 2.25rem);
}
.dress__col + .dress__col {
  border-top: 1px solid var(--line-soft);
  padding-top: clamp(1.5rem, 4vw, 2rem);
}
@media (min-width: 720px) {
  /* Column gap is 0 and the space is made from equal padding either side of the
     rule instead, so the rule sits centred and both columns stay the same width. */
  .dress__cols { grid-template-columns: 1fr 1fr; column-gap: 0; }
  .dress__col:first-child { padding-right: clamp(2rem, 5vw, 3.5rem); }
  .dress__col + .dress__col {
    border-top: 0; padding-top: 0;
    border-left: 1px solid var(--line-soft);
    padding-left: clamp(2rem, 5vw, 3.5rem);
  }
}
.dress__label { font-weight: 400; color: var(--gold-ink); margin-bottom: 0.6rem; }
.dress__col .body { margin: 0; }

.dress__note {
  text-align: center; max-width: 46ch;
  /* No bottom margin: this is the last thing in the section now, so the gap
     under it is the section's own padding. It used to carry extra space here
     to stand the photo grid off, and that grid has gone. */
  margin: clamp(1.75rem, 4vw, 2.5rem) auto 0;
  font-family: var(--serif); font-style: italic;
  font-size: 1rem; letter-spacing: 0.01em; color: var(--ink-3);
}

/* =============================================================================
   5b. Photo break
   ========================================================================== */
/* Full-bleed, no padding of its own, no type over it. Sizing is by
   aspect-ratio on the figure rather than a height on the image, so the box is
   known before the photo loads and the sections below it never shift.

   The ratio changes with the viewport because one crop can't serve both ends.
   The source is a portrait selfie (1200x1600), and `cover` in a wide box
   keeps only a horizontal slice of it — a square box keeps 1200 of those
   1600 rows, 16/7 keeps about 525 of them, a third of the frame.

   object-position is therefore set per ratio and none of them is 50%. The
   faces sit above the middle of the source (hair from ~28% down the frame,
   chins at ~56%), so centring the crop on the source is not the same as
   centring it on them: at 16/7 a 50% crop would start below the tops of their
   heads. Each value below is the one that keeps hair-to-shoulders inside the
   slice at that ratio, and the tighter the ratio the less slack there is —
   at 16/7 a couple of percent is the whole margin. Re-check these if the
   source photo is ever swapped or re-exported. */
.photo-break {
  margin: 0;
  aspect-ratio: 1 / 1;
  overflow: hidden;
}
.photo-break img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: 50% 46%;
}

/* Two steps rather than one. Going straight from 1/1 to 16/7 at a single
   breakpoint drops the band from full-width-tall to a third of that in one
   pixel; the middle step makes it a taper instead of a jump. */
@media (min-width: 48em) {
  .photo-break { aspect-ratio: 16 / 10; }
  .photo-break img { object-position: 50% 44%; }
}

/* Desktop drops it entirely. The source is a portrait phone photo, and every
   desktop treatment of it was a compromise: full-bleed turned it into a
   banner, and the wide crops it needs to fill a wide box throw away most of
   the frame. It earns its place at the widths where it can stay tall, so the
   travel notes hand straight to the RSVP here. Nothing else keys off this
   breakpoint — restoring it means deleting this block, and the 16/10 step
   above then carries all the way up. */
@media (min-width: 64em) {
  .photo-break { display: none; }
}

/* =============================================================================
   6. RSVP
   ========================================================================== */
.rsvp { padding-block: var(--pad-y); }
.rsvp .script { margin-bottom: 1.25rem; }
.form { margin-top: clamp(2.5rem, 6vw, 3.5rem); display: grid; gap: 1.75rem; text-align: left; }

.field { display: grid; gap: 0.45rem; }
.field > label {
  font-size: 0.625rem; letter-spacing: 0.24em; text-transform: uppercase; color: var(--ink-2);
}
.field__opt { text-transform: none; letter-spacing: 0.04em; color: var(--ink-3); }
.field__help { font-size: 0.75rem; line-height: 1.6; color: var(--ink-3); letter-spacing: 0.03em; }

.field input[type="text"], .field input[type="email"], .field textarea {
  font: inherit; letter-spacing: 0.03em;
  width: 100%; color: var(--ink); background: var(--field);
  border: 0; border-bottom: 1px solid var(--line);
  border-radius: 0; padding: 0.7rem 0.25rem;
  transition: border-color 0.35s var(--ease);
}
.field textarea { resize: vertical; min-height: 5rem; }
.field input::placeholder, .field textarea::placeholder { color: var(--ink-3); opacity: 1; }
.field input:focus-visible, .field textarea:focus-visible {
  outline: none; border-bottom-color: var(--gold-ink); border-bottom-width: 1px;
  box-shadow: 0 1px 0 var(--gold-ink);
}
.field__error { color: var(--error); font-size: 0.75rem; line-height: 1.6; letter-spacing: 0.03em; }
.field.has-error input, .field.has-error textarea { border-bottom-color: var(--error); }

.guests { display: grid; gap: 2.25rem; }
.guest {
  display: grid; gap: 1.4rem;
  padding-top: 1.75rem;
  border-top: 1px solid var(--line-soft);
}
.guest:first-child { border-top: 0; padding-top: 0; }
.guest__head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.guest__n {
  font-family: var(--serif); font-style: italic;
  font-size: 1.125rem; color: var(--ink-2); letter-spacing: 0.02em;
}
.guest__remove {
  background: none; border: 0; cursor: pointer;
  display: inline-flex; align-items: center; gap: 0.4rem;
  font-size: 0.625rem; letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--ink-3); padding: 0.3rem; border-radius: 999px;
  transition: color 0.3s var(--ease);
}
.guest__remove:hover { color: var(--error); }
@media (prefers-reduced-motion: no-preference) { .guest { animation: rise 0.6s var(--ease) both; } }

.choice { display: grid; gap: 0.45rem; }
.choice__legend { font-size: 0.625rem; letter-spacing: 0.24em; text-transform: uppercase; color: var(--ink-2); }
.choice__opts { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin-top: 0.4rem; }
.choice__opt { position: relative; }
.choice__opt input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; margin: 0; cursor: pointer; }
.choice__opt span {
  display: flex; align-items: center; justify-content: center; text-align: center;
  min-height: 46px; padding: 0.6rem 0.9rem;
  border: 1px solid var(--line); border-radius: 999px;
  font-size: 0.625rem; letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--ink-2);
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease), border-color 0.35s var(--ease);
}
.choice__opt input:checked + span { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.choice__opt input:focus-visible + span { outline: 1px solid var(--gold-ink); outline-offset: 3px; }
.choice.has-error .choice__opt span { border-color: var(--error); }

.guests__add { justify-self: start; }
.form__submit { justify-self: center; min-width: 240px; margin-top: 0.75rem; }
.form__submit.is-sending { opacity: 0.55; pointer-events: none; }
.form__status { text-align: center; font-size: 0.8125rem; line-height: 1.7; min-height: 1.7em; }
.form__status.is-error { color: var(--error); }
.form__status.is-ok { color: var(--ink-3); }

.form-done {
  text-align: center; display: grid; justify-items: center; gap: 0.85rem;
  padding: clamp(3rem, 8vw, 4.5rem) 1.5rem;
  border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft);
  margin-top: clamp(2.5rem, 6vw, 3.5rem);
}
.form-done__icon { width: 3rem; height: 3rem; color: var(--gold); }

/* =============================================================================
   8. Stay
   ========================================================================== */
.stay { padding-block: var(--pad-y); }
.stay__grid { display: grid; grid-template-columns: 1fr; gap: clamp(3rem, 7vw, 4.5rem); }
@media (min-width: 880px) { .stay__grid { grid-template-columns: 1fr 1fr; } }
.stay .body { margin-top: 0.75rem; }

/* Sits between the heading and the copy, so it reads as the first thing said
   rather than a footnote — the reassurance is the point of this column, and
   the timings underneath are only the how. Serif italic, the same aside voice
   the dress-code note uses. Same top gap as .stay .body: it is taking that
   slot under the heading, not adding a new one. */
.stay__note {
  margin-top: 0.75rem;
  max-width: 46ch;
  font-family: var(--serif); font-style: italic;
  font-size: 0.9375rem; letter-spacing: 0.01em; line-height: 1.7;
  color: var(--ink-3);
}

.list { margin-top: clamp(1.5rem, 3.5vw, 2.25rem); border-top: 1px solid var(--line-soft); }
.list li { padding-block: 1.1rem; }
.list li + li { border-top: 1px solid var(--line-soft); }
.list__name { font-family: var(--serif); font-weight: 400; font-size: 1.125rem; line-height: 1.4; letter-spacing: 0.02em; }
.list__note { color: var(--ink-3); font-size: 0.8125rem; line-height: 1.75; }

.ways { margin-top: clamp(1.5rem, 3.5vw, 2.25rem); display: grid; gap: 1.75rem; }
.way { display: grid; grid-template-columns: 42px 1fr; gap: 1rem; align-items: start; }
.way__icon { width: 2rem; height: 2rem; color: var(--gold); margin-top: 0.15rem; }

/* =============================================================================
   9. Good to know
   ========================================================================== */
.faqs { padding-block: var(--pad-y); }
.faqs .script { margin-bottom: clamp(2rem, 5vw, 3rem); }
.qa { margin-top: clamp(2.25rem, 5vw, 3rem); }
.qa__q { color: var(--ink); letter-spacing: 0.2em; text-indent: 0.2em; margin-bottom: 0.85rem; }
.qa .body { margin-inline: auto; max-width: 58ch; }

/* =============================================================================
   Footer
   ========================================================================== */
.footer { padding-block: clamp(4rem, 9vw, 6rem); border-top: 1px solid var(--line-soft); text-align: center; }
.footer__inner { display: grid; justify-items: center; gap: 1.15rem; }
