/* ==========================================================================
   Wedding site — public design system
   ==========================================================================

   "Desert Light": warm, low-contrast-at-the-edges, generous. Cream paper,
   soft rounded cards, a warm accent, and photographs in arch-shaped frames
   borrowed from a courtyard doorway. At night it is the same paper in a room
   with the lights off: the same hue, the same accent, the same architecture of
   type and space, with the lightness axis turned over. See "Night" below.

   Three rules govern everything below.

   1. NOTHING here hardcodes a colour. Every surface, tint and shadow is
      derived from the five custom properties base.html emits from the
      database (--cfg-primary, --cfg-accent, --cfg-background, --cfg-surface,
      --cfg-text) plus --radius and --heading-font / --body-font. The couple
      change all of them in admin -> Configuracion, and this file has to
      follow. That is why the "sand" surfaces below are color-mix() against
      --surface rather than the literal #f7f0e6 they were designed with: a
      literal would be a second, invisible palette that only works until
      someone picks a colour.

      Those five arrive under the --cfg-* names precisely so this file can
      decide what --primary / --surface / --text MEAN, which is what a second
      set of values - a dark theme - needs. base.html cannot set --primary
      itself, because the inline <style> it lives in comes after this
      stylesheet in the <head> and would win: the couple's own value would
      silently outrank the theme. See the Night block for what --primary means
      at 2am.

   2. Nothing here hides content behind a JavaScript animation. A section
      that is invisible until GSAP runs is a section that is permanently
      invisible if an ad blocker eats the script, so reveal animations
      start from the visible state (see site.js).

   3. Every colour is a token, including the handful that have no database
      setting (the red of an urgent notice, the two flash colours). A literal
      inside a rule is a colour that cannot follow the theme, and the night
      below is what finds them.

   Loaded after tailwind.css on purpose: Tailwind's preflight resets inputs
   to `background-color: transparent`, and the hand-written rules here have
   the last word on any overlap. See .field below for why that matters.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Tokens
   -------------------------------------------------------------------------- */
:root {
  color-scheme: light;

  /* ---- The night.
     Four values, and everything at night is one of them or a tint of them.
     They come from the couple's own two colours rather than from a second
     palette, for the same reason rule 1 exists: a dark theme that hardcodes
     its own greys is a second invisible palette that only agrees with the
     admin's colour pickers until someone picks a colour.

     The ground and the card are the couple's ink thinned toward black. The
     ink is the paper rebuilt from their accent and white, so the type is the
     same warm off-white the day theme's paper is, and the accent keeps the
     hue it was chosen with. 34% / 78% is the whole elevation ladder of the
     night theme - the gap between them is about six percent of the ink's
     range, which is all the separation a colour this dark has to give, and
     which is why the night needs its shadows to be black. */
  --night-ground: color-mix(in srgb, var(--cfg-primary) 34%, #000);
  --night-card: color-mix(in srgb, var(--cfg-primary) 78%, #000);
  --night-ink: color-mix(in srgb, var(--cfg-accent) 14%, #fff);
  --night-accent: color-mix(in srgb, var(--cfg-accent) 34%, #fff);

  /* ---- The five the couple chose, passed straight through. Everything below
     and most of this file are written against these five names and nothing
     else, so a theme is a change to what they mean and not to a single rule. */
  --primary: var(--cfg-primary);
  --accent: var(--cfg-accent);
  --accent-ink: var(--day-accent-ink);
  --background: var(--cfg-background);
  --surface: var(--cfg-surface);
  --text: var(--cfg-text);

  /* --accent-ink is the accent darkened just enough to be legible as SMALL
     text. The accent itself is a brand colour chosen by the couple and is
     perfectly good for large elements, but it was being used for .eyebrow -
     0.64rem uppercase - where it measures 3.72:1 on the page background and
     fails WCAG AA's 4.5:1 for body text. Mixing 85% toward black gives
     4.84:1 on background and 5.44:1 on surface. Not a different colour
     choice, a different use: the accent keeps its role, and only small text
     gets the ink variant. (It was defined in base.html, next to the raw
     values; it belongs with the rest of the design system, and now that the
     dark theme has to derive its own version of it, so does it live here.)
     scripts/check_contrast.py measures both. */
  --day-accent-ink: color-mix(in srgb, var(--accent) 85%, #000);

  /* ---- The ink on the envelope's paper.
     The .letter card keeps a fixed cream gradient in both themes - it is a
     photographed object, and a black envelope on a black page is not one -
     so its ink has to be fixed too, or the theme flips one side of the pair
     and not the other. Deriving it from --cfg-primary rather than hardcoding a
     brown keeps rule 1 intact: the ink on the cream paper is the couple's own
     ink, which is also what --panel is.
     NOT flipped in the Night block. That is the whole point, and it is the
     reason this token is not just "use --panel": --panel means "a dark band on
     the page" while this means "ink on cream paper", and they would drift. */
  --letter-ink: var(--cfg-primary);
  --letter-ink-soft: color-mix(in srgb, var(--letter-ink) 65%, transparent);

  /* ---- The dark bands.
     --primary is the couple's ink by day and the colour of the paper at
     night (see Night), so the big ink blocks - the RSVP panel, the
     after-party band - cannot keep reading from it or they would turn into
     two enormous ivory slabs in the middle of a dark page. --panel is the
     couple's ink in BOTH themes: the one thing on the site that does not
     change with the time of day, because it is a colour they chose rather
     than a surface. --on-panel is the ink that goes on it, and --on-solid the
     ink that goes on a solid fill (which is the paper at night, so a solid
     fill needs the ground on it). */
  --panel: var(--cfg-primary);
  --on-panel: var(--cfg-background);
  --on-solid: #fff;

  /* ---- Status. The urgent notice is the one place on the site that is red,
     and the red is fixed so that "important" looks the same whatever palette
     is configured - the accent cannot carry that, since it is a colour the
     couple picked against their own background and is used for eyebrows and
     links all over the site. These are tokens rather than literals in the
     rules that use them because guest-tools.css is loaded after this file and
     a night override here would lose the cascade to it. */
  --urgent: #b3261e;
  --flash-error: #fce7e7;
  --flash-error-ink: #7a1d1d;
  --flash-ok: #e7f6ed;
  --flash-ok-ink: #14532d;

  /* Warm paper. --surface is the couple's white; tinted with a little accent
     it becomes the "sand" the whole design sits on, which is what makes the
     page feel like paper stock rather than a screen. The day expressions are
     named so the night block can hand them to light-dark() without restating
     the arithmetic - a copy of a formula is a formula that will be edited in
     one place and not the other. */
  --sand: var(--day-sand);
  --sand-deep: var(--day-sand-deep);
  --card: var(--day-card);
  --day-sand: color-mix(in srgb, var(--accent) 7%, var(--surface));
  --day-sand-deep: color-mix(in srgb, var(--accent) 13%, var(--surface));
  --day-card: color-mix(in srgb, var(--surface) 92%, var(--accent));

  /* Hairlines. One value, used everywhere a rule is needed, so the page has
     exactly one border weight and it cannot drift. It follows --text, so by
     day it is a warm grey line on cream and at night a warm grey line on
     charcoal, which is the one thing about a dark theme that is easier than
     a light one. */
  --line: color-mix(in srgb, var(--text) 12%, transparent);
  --line-soft: color-mix(in srgb, var(--text) 7%, transparent);

  /* Shadows are tinted with the couple's ink rather than pure black. A black
     shadow on cream reads as dirt; a warm one reads as depth. This is most of
     the difference between "soft" and "muddy". At night the tint is black
     instead: the same percentages of ivory would be a glow around every card,
     and elevation on a dark page comes from the surface ladder in the night
     palette, not from the shadow. */
  --shadow-ink: var(--cfg-text);
  --lift-sm: 0 2px 10px -4px color-mix(in srgb, var(--shadow-ink) 16%, transparent);
  --lift: 0 14px 38px -22px color-mix(in srgb, var(--shadow-ink) 30%, transparent);
  --lift-lg: 0 34px 80px -40px color-mix(in srgb, var(--shadow-ink) 38%, transparent);

  /* The arch. Photos sit in a frame whose top is a half-round, the shape of
     the doorway in a courtyard wall. 46% of the width on the horizontal
     radii and 38% on the vertical ones gives a proper semicircle rather than
     the squashed ellipse a single percentage gives you. */
  --arch: 46% 46% var(--radius) var(--radius) / 38% 38% var(--radius) var(--radius);

  --shell: 74rem;
  --gutter: 1.5rem;
  --section: 6.5rem;
}

/* --------------------------------------------------------------------------
   Night
   --------------------------------------------------------------------------
   One block, and the only one in the file that changes what a colour means.

   The theme follows the device (`color-scheme: light dark` makes every
   light-dark() below resolve by the reader's own preference, and it is also
   what makes the browser's own scrollbars, form controls and the area behind
   an overscroll go dark - free, and correct) and the header's toggle
   overrides it by setting data-theme on <html>. base.html renders that
   attribute from a cookie, so a guest who chose a theme gets it in the very
   first byte and never sees the other one; a guest who chose nothing gets
   their device's, which is the correct default and the one they will keep.

   Gated on @supports rather than left to degrade. light-dark() arrived in
   March 2024, and a declaration that uses it is DROPPED AT PARSE TIME where
   the function does not exist - which would leave every one of these tokens
   unresolved and the page without any colours at all, on exactly the old
   phone that can least afford it. Inside the gate a browser that cannot flip
   a token keeps the day values above, which is the site as it shipped.

   Everything here is a token. The two rules that could not be - the flash
   colours and the red of the urgent notice - are tokens at the top instead,
   so there is no rule anywhere in this file that a theme cannot reach. */
@supports (color: light-dark(#fff, #000)) {
  :root {
    color-scheme: light dark;
    /* The page, and the ink that goes on a solid fill: the ground at night. */
    --background: light-dark(var(--cfg-background), var(--night-ground));
    /* Cards, fields, chips, the letterbox behind a photograph. */
    --surface: light-dark(var(--cfg-surface), var(--night-card));
    /* Body text, and the fill of a solid action: the paper at night, because
       a button on a page this dark has to be the brightest thing in it. */
    --text: light-dark(var(--cfg-text), var(--night-ink));
    --primary: light-dark(var(--cfg-primary), var(--night-ink));
    --accent-ink: light-dark(var(--day-accent-ink), var(--night-accent));
    --on-panel: light-dark(var(--cfg-background), var(--night-ink));
    --on-solid: light-dark(#fff, var(--night-ground));
    /* The accent itself does not change: it is only ever used as a tint on a
       surface, as a ring, or at display size, and the same hue in both themes
       is what makes the two read as one site. */
    --sand: light-dark(var(--day-sand), var(--night-ground));
    --sand-deep: light-dark(var(--day-sand-deep),
                            color-mix(in srgb, var(--accent) 6%, var(--night-ground)));
    --card: light-dark(var(--day-card), var(--night-card));
    /* How a raised surface says so.
     *
     * By day a card casts a shadow: there is a darker colour beneath it than
     * the page it sits on, so a soft warm edge reads as height. At night there
     * is nothing darker than the page to cast onto - the ground is the couple's
     * own ink at 34% - so a black shadow is invisible, and the only thing left
     * telling a card from the paper is the six RGB units between the ground and
     * the card: a difference the eye finds on a clean screen in a dark room and
     * misses on a phone in daylight.
     *
     * So at night the lift also carries a hairline - a one-pixel inset rim in
     * the ink's own colour, at roughly the weight the day shadow has. The
     * shadow stays in the list either way; it just stops being the only thing
     * doing the work.
     *
     * Note the shape of this. light-dark() takes a COLOUR, so it appears only
     * on the colour component and each branch is `transparent` or a real rim;
     * the shadow's geometry is shared, because putting the whole shadow list
     * inside one light-dark() is not a valid colour and the declaration is
     * dropped at parse time - which is exactly what happened the first time,
     * leaving every card on the site with no edge at all and no error anywhere. */
    --lift-sm: 0 2px 10px -4px color-mix(in srgb, var(--shadow-ink) 16%, transparent),
               inset 0 0 0 1px light-dark(transparent, color-mix(in srgb, var(--text) 7%, transparent));
    --lift: 0 14px 38px -22px color-mix(in srgb, var(--shadow-ink) 30%, transparent),
            inset 0 0 0 1px light-dark(transparent, color-mix(in srgb, var(--text) 9%, transparent));
    --lift-lg: 0 34px 80px -40px color-mix(in srgb, var(--shadow-ink) 38%, transparent),
               inset 0 0 0 1px light-dark(transparent, color-mix(in srgb, var(--text) 11%, transparent));
    --urgent: light-dark(#b3261e, #f0a9a2);
    --flash-error: light-dark(#fce7e7, #2e1614);
    --flash-error-ink: light-dark(#7a1d1d, #f5c6c0);
    --flash-ok: light-dark(#e7f6ed, #12251a);
    --flash-ok-ink: light-dark(#14532d, #b8e6c8);
  }
  /* The guest's own choice, which outranks their device either way. */
  :root[data-theme="light"] { color-scheme: light; }
  :root[data-theme="dark"] { color-scheme: dark; }
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
/* Anchored sections clear the sticky header instead of hiding under it. */
:target { scroll-margin-top: 5.5rem; }
body {
  margin: 0;
  background: var(--sand);
  color: var(--text);
  font-family: var(--body-font);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; }
img { max-width: 100%; }
button { font: inherit; }
:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 3px; border-radius: 4px; }

/* --------------------------------------------------------------------------
   Type
   -------------------------------------------------------------------------- */
.editorial-serif { font-family: var(--heading-font); }

/* The one place a small-caps label is defined. Every eyebrow on the site is
   this class, which is what keeps 30 of them from being 30 slightly different
   sizes. */
.eyebrow {
  margin: 0;
  color: var(--accent-ink);
  font-size: .64rem;
  font-weight: 600;
  letter-spacing: .28em;
  text-transform: uppercase;
}
/* On a photograph the accent cannot be trusted: it is a brand colour chosen
   against the page background, not against whatever is in the image. */
.on-photo .eyebrow { color: #fff; opacity: .88; }

.display-title {
  max-width: 9ch;
  margin: 1.1rem 0 0;
  font-family: var(--heading-font);
  font-weight: 400;
  font-size: clamp(3.4rem, 8.4vw, 7rem);
  line-height: .88;
  letter-spacing: -.05em;
}
.feature-title {
  margin: .7rem 0 0;
  font-family: var(--heading-font);
  font-weight: 400;
  font-size: clamp(2.6rem, 5.4vw, 4.4rem);
  line-height: .92;
  letter-spacing: -.04em;
}
.section-heading h2 {
  margin: .7rem 0 0;
  font-family: var(--heading-font);
  font-weight: 400;
  font-size: clamp(2.4rem, 4.6vw, 3.9rem);
  line-height: .96;
  letter-spacing: -.035em;
}
.section-heading__copy {
  max-width: 32rem;
  padding-top: 1.4rem;
  font-size: 1.02rem;
  line-height: 1.75;
  opacity: .68;
}
.lede { font-size: 1.06rem; line-height: 1.75; opacity: .72; }

/* --------------------------------------------------------------------------
   Layout
   -------------------------------------------------------------------------- */
.section-shell { width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto; padding: var(--section) 0; }
.section-shell--tight { padding: 3.5rem 0; }
.section-heading { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 3rem; align-items: start; margin-bottom: 3rem; }
.home-main { overflow: hidden; padding-bottom: 0; }

.skip-link {
  position: fixed; z-index: 2000; left: 1rem; top: -5rem;
  padding: .7rem 1rem; border-radius: 99px;
  background: var(--primary); color: var(--background);
  transition: top .2s;
}
.skip-link:focus { top: 1rem; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

/* --------------------------------------------------------------------------
   Header
   -------------------------------------------------------------------------- */
.site-header {
  position: sticky; top: 0; z-index: 40;
  border-bottom: 1px solid transparent;
  background: color-mix(in srgb, var(--sand) 86%, transparent);
  backdrop-filter: blur(16px);
  transition: border-color .3s, box-shadow .3s;
}
/* Set by shell.js once the page scrolls. A border that only appears after a
   scroll event would flash on the first pixel of movement, so the class is
   the thing that changes, not the property. */
.site-header.is-stuck { border-bottom-color: var(--line); box-shadow: var(--lift-sm); }
.site-header__inner {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem;
  width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto;
  padding: 1rem 0;
}
.brand {
  display: inline-flex; align-items: center; gap: .5rem; width: max-content;
  text-decoration: none; font-family: var(--heading-font);
  font-size: 1.4rem; line-height: 1; letter-spacing: -.01em;
}
.brand__mark { color: var(--accent-ink); font-size: 1.3em; line-height: 0; }
.site-nav { display: flex; gap: 1.4rem; font-size: .7rem; letter-spacing: .16em; text-transform: uppercase; }
.site-nav a { text-decoration: none; opacity: .6; padding: .35rem 0; border-bottom: 1px solid transparent; transition: opacity .2s, border-color .2s; }
.site-nav a:hover, .site-nav a:focus-visible { opacity: 1; border-bottom-color: var(--accent-ink); }
/* Nav entries that point at a section on this page scroll; the ones that point
   at another page navigate. Only the latter get an arrow, so "this is another
   page" is visible without opening it. */
.site-nav a[href^="/#"]::after { content: ""; }
.site-nav a[href="/fotos"]::after,
.site-nav a[href="/guestbook"]::after { content: " \2197"; font-size: .85em; opacity: .5; }
/* The right-hand cell. A flex row rather than two siblings in the grid,
   because there are now three things in it - the theme toggle, the RSVP
   button, and (under 46rem) the menu button - and three grid children in a
   1fr auto 1fr row means the middle column grows and the brand stops being
   where it was. */
.header-actions {
  justify-self: end;
  display: flex; align-items: center; gap: .5rem;
}
.header-rsvp {
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .6rem 1rem; border-radius: 99px;
  background: var(--primary); color: var(--background);
  font-size: .66rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
  text-decoration: none;
  transition: transform .2s;
}
.header-rsvp:hover { transform: translateY(-1px); }

/* The theme toggle.
 *
 * A 34px circle rather than another pill in the header: the header already
 * carries the wedding name, eight nav entries and an RSVP button, and this is
 * the only control on the page a guest may never use. It has to be findable
 * with a thumb and out of the way of the reading.
 *
 * `aria-pressed` names the state it controls ("Tema oscuro" = dark is on) and
 * the ICON names the other one: a moon by day, because that is where pressing
 * it takes you, and a sun at night, because that is the way back. Mixing those
 * two conventions is deliberate - a sighted guest should not have to remember
 * which way round the button is, and a screen reader should be told a state
 * rather than a destination.
 *
 * The icon swap is keyed on the same two signals as the tokens (the device
 * preference, then data-theme) because it has to be right BEFORE the first
 * paint: a toggle that shows the wrong glyph until shell.js runs is a visible
 * flash on every page load, and shell.js is at the end of the body. The
 * duplication here is four selectors and no colour values - the alternative,
 * reading the state in JavaScript, cannot be correct on the first frame. */
.theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.15rem; height: 2.15rem; flex: none;
  border: 1px solid var(--line); border-radius: 50%;
  background: var(--surface); color: var(--text);
  cursor: pointer; opacity: .72;
  transition: opacity .2s, transform .2s, border-color .2s;
}
.theme-toggle:hover { opacity: 1; transform: translateY(-1px); border-color: var(--accent-ink); }
.theme-toggle__icon { width: 1.05rem; height: 1.05rem; display: block; }
.theme-toggle__sun { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle__sun { display: inline; }
  :root:not([data-theme="light"]) .theme-toggle__moon { display: none; }
}
:root[data-theme="dark"] .theme-toggle__sun { display: inline; }
:root[data-theme="dark"] .theme-toggle__moon { display: none; }
@media (prefers-reduced-motion: reduce) {
  .theme-toggle { transition: none; }
  .theme-toggle:hover { transform: none; }
}

/* Mobile menu. A button that exists only under 700px, and a panel that is
   display:none above it, so neither is in the tab order on a desktop. */
.nav-toggle {
  display: none;
  align-items: center; gap: .5rem;
  padding: .55rem .8rem; border: 1px solid var(--line); border-radius: 99px;
  background: var(--surface); cursor: pointer;
  font-size: .64rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;
}
.nav-toggle__bars { display: grid; gap: 3px; width: 15px; }
.nav-toggle__bars span { display: block; height: 1.5px; background: currentColor; border-radius: 2px; transition: transform .25s, opacity .25s; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars span:nth-child(1) { transform: translateY(4.5px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars span:nth-child(3) { transform: translateY(-4.5px) rotate(-45deg); }
.mobile-nav { display: none; border-top: 1px solid var(--line); background: var(--sand); }
.mobile-nav.is-open { display: block; }
.mobile-nav__inner { width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto; padding: .5rem 0 1.25rem; }
.mobile-nav a {
  display: flex; align-items: center; justify-content: space-between;
  padding: .85rem 0; border-bottom: 1px solid var(--line-soft);
  text-decoration: none; font-size: .82rem; letter-spacing: .1em; text-transform: uppercase;
}
.mobile-nav a:last-child { border-bottom: 0; }
.mobile-nav .mobile-nav__cta { margin-top: 1rem; justify-content: center; border: 0; border-radius: 99px; padding: .9rem; background: var(--primary); color: var(--background); font-weight: 600; }

/* --------------------------------------------------------------------------
   Buttons, chips, links
   -------------------------------------------------------------------------- */
.button {
  display: inline-flex; align-items: center; justify-content: center; gap: .55rem;
  padding: .95rem 1.5rem; border-radius: 99px; border: 1px solid transparent;
  text-decoration: none; cursor: pointer;
  font-size: .7rem; font-weight: 600; letter-spacing: .13em; text-transform: uppercase;
  transition: transform .2s, box-shadow .2s, background-color .2s, border-color .2s;
}
.button:hover { transform: translateY(-2px); box-shadow: var(--lift); }
.button--primary { background: var(--primary); color: var(--background); }
.button--accent { background: var(--accent-ink); color: var(--on-solid); }
.button--light { background: #fff; color: #211f1d; }
.button--dark { background: var(--primary); color: var(--on-solid); }
/* An outlined button over a photograph cannot rely on the accent or on a
   background tint: the only thing guaranteed is the photograph. So it gets a
   real border and a scrim behind the text. */
.button--ghost { border-color: color-mix(in srgb, currentColor 42%, transparent); }
.on-photo .button--ghost { border-color: rgba(255, 255, 255, .6); background: rgba(20, 15, 10, .22); }
.hero-actions { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 2.2rem; }

.chip {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .48rem .9rem; border-radius: 99px;
  border: 1px solid var(--line);
  background: var(--surface);
  font-size: .72rem; font-weight: 500; text-decoration: none;
  transition: border-color .2s, transform .2s;
}
a.chip:hover { border-color: var(--accent-ink); transform: translateY(-1px); }
.chip--quiet { background: transparent; color: inherit; opacity: .8; }
.btn-row { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
.btn-row__label { font-size: .6rem; letter-spacing: .2em; text-transform: uppercase; opacity: .5; margin-right: .2rem; }
.text-link {
  display: inline-flex; align-items: center; gap: .4rem;
  color: var(--accent-ink); font-weight: 600; font-size: .72rem;
  letter-spacing: .12em; text-transform: uppercase; text-decoration: none;
}
.text-link:hover { text-decoration: underline; text-underline-offset: 4px; }

/* --------------------------------------------------------------------------
   Forms
   -------------------------------------------------------------------------- */

/* THE BUG THIS CLASS EXISTS TO FIX.

   Tailwind v4's preflight ships:

       input, select, optgroup, textarea { background-color: #0000; ... }

   which means every <input> on the site has a TRANSPARENT background. The
   homepage's invitation-link field had its own rule setting only `color`, no
   background, and it lives inside .rsvp-panel whose background is --primary
   (#211f1d). A transparent input with #211f1d text on a #211f1d panel is
   invisible: the single most important control on the landing page rendered
   as blank space next to its own button. It looked fine in the template and
   was gone in the browser.

   So: every field on this site sets its own background explicitly. If you add
   an input, give it .field. The regression test for this is
   tests/landing.test.py::test_inputs_are_never_transparent_on_a_dark_panel.
   -------------------------------------------------------------------------- */
.field {
  width: 100%;
  border: 1px solid var(--line);
  border-radius: calc(var(--radius) * .55);
  padding: .8rem 1rem;
  background: var(--surface);
  color: var(--text);
  font: inherit;
  font-size: .92rem;
}
.field::placeholder { color: color-mix(in srgb, var(--text) 42%, transparent); }
/* A dark panel needs a light field. Selected explicitly rather than by
   ancestor selector so the intent is visible where the field is defined. */
.on-dark .field { background: rgba(255, 255, 255, .1); border-color: rgba(255, 255, 255, .28); color: #fff; }
.on-dark .field::placeholder { color: rgba(255, 255, 255, .68); }
textarea.field { min-height: 7rem; resize: vertical; }

.form-row { display: grid; gap: .6rem; }

/* --------------------------------------------------------------------------
   Cards
   -------------------------------------------------------------------------- */
.card {
  padding: 1.6rem 1.5rem;
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--lift);
}
.card h3 { margin: .7rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: 1.9rem; line-height: 1.05; letter-spacing: -.02em; }
.card p { margin: .5rem 0 0; }
.card p + p { margin-top: .35rem; }
.card__meta { font-size: .86rem; line-height: 1.6; opacity: .7; }
.card--tint { background: var(--sand-deep); box-shadow: none; }

/* The date/venue plate. One small object that carries the two facts a guest
   needs before anything else, and which on the current site were set as loose
   text in the hero with no relationship to each other. */
.plate {
  display: inline-flex; flex-direction: column; gap: .35rem;
  width: max-content; max-width: 100%;
  margin: 2rem 0 0; padding: 1.05rem 1.5rem;
  border-radius: calc(var(--radius) * .7);
  background: var(--surface);
  box-shadow: var(--lift);
}
/* A <dl> plate: the date is the term, the time and place are its definition.
   Both are reset because a browser's default dl margins and dd indent would
   otherwise open a gap and push the qualifier off to the right. */
.plate dt { font-family: var(--heading-font); font-weight: 400; font-size: 1.3rem; line-height: 1.1; }
.plate dd { margin: 0; font-size: .63rem; letter-spacing: .2em; text-transform: uppercase; opacity: .58; }
/* The <b>/<span> form, used on the error pages and the RSVP deadline, where the
   two are genuinely a label and a value rather than a term and definition. */
.plate > b { font-family: var(--heading-font); font-weight: 400; font-size: 1.3rem; line-height: 1.1; }
.plate > span { font-size: .63rem; letter-spacing: .2em; text-transform: uppercase; opacity: .58; }

/* A plate that sits ON a photograph cannot be a white card - it reads as a
   sticker over the image and, worse, the two surfaces are unrelated so the eye
   sees two competing objects. Tinted glass instead: the photo shows through,
   the text keeps its contrast, and it belongs to the picture. */
.on-photo .plate {
  background: rgba(20, 15, 10, .42);
  border: 1px solid rgba(255, 255, 255, .22);
  box-shadow: none;
  backdrop-filter: blur(10px);
}
.on-photo .plate dt, .on-photo .plate > b { color: #fff; }
.on-photo .plate dd, .on-photo .plate > span { color: #fff; opacity: .8; }

/* --------------------------------------------------------------------------
   Photo frames
   -------------------------------------------------------------------------- */
.frame { position: relative; overflow: hidden; border-radius: var(--radius); background: var(--surface); box-shadow: var(--lift); }
.frame img { width: 100%; height: 100%; object-fit: cover; }
.frame--arch { border-radius: var(--arch); }
.frame--arch-sm { border-radius: 42% 42% var(--radius) var(--radius) / 30% 30% var(--radius) var(--radius); }
/* Portrait crop for a portrait photograph; a landscape photograph in a 3/4 box
   loses the two things that matter about it, so the ratio is chosen per item
   by the template from the stored width/height rather than fixed here. */
.frame--portrait { aspect-ratio: 3 / 4; }
.frame--landscape { aspect-ratio: 4 / 3; }
.frame--square { aspect-ratio: 1; }
.frame figcaption {
  position: absolute; left: 1.1rem; right: 1.1rem; bottom: 1rem;
  color: #fff; font-size: .78rem; line-height: 1.4;
  text-shadow: 0 1px 12px rgba(0, 0, 0, .55);
}

/* --------------------------------------------------------------------------
   Hero
   -------------------------------------------------------------------------- */
.hero { position: relative; display: grid; align-items: center; background: var(--sand-deep); }
.hero__inner { width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto; padding: clamp(3rem, 7vw, 5.5rem) 0; }
/* Landscape photograph: full bleed, because a 16:9 image cropped into a
   portrait frame loses its subject. --panel and not --primary: the wash and
   the white type on top of it are a photograph treatment, and at night the
   fallback behind a missing photo has to stay the couple's ink rather than
   become an ivory rectangle with white type on it. */
.hero--bleed { position: relative; min-height: min(46rem, calc(100svh - 4.5rem)); color: #fff; background: var(--panel); }
.hero--bleed .hero__photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.hero--bleed .hero__wash { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(20, 15, 10, .45) 0%, rgba(20, 15, 10, .12) 40%, rgba(20, 15, 10, .74) 100%); }
.hero--bleed .hero__inner { position: relative; z-index: 1; padding-block: clamp(4rem, 12vh, 7rem) clamp(3rem, 8vh, 5rem); }
/* Portrait photograph: type beside it in an arch. A 3:4 image already has the
   proportions the arch wants, so nothing important is cropped away. */
.hero--split .hero__inner { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); gap: clamp(2rem, 5vw, 4.5rem); align-items: center; }
.hero__copy { max-width: 34rem; }
.hero--bleed .hero__copy { max-width: none; }

/* --------------------------------------------------------------------------
   Countdown
   -------------------------------------------------------------------------- */
.countdown-band { border-block: 1px solid var(--line); background: var(--sand); }
.countdown { display: grid; grid-template-columns: repeat(4, 1fr); gap: .8rem; max-width: 56rem; margin-inline: auto; text-align: center; }
.countdown > div { padding: 1.4rem .5rem; border-radius: calc(var(--radius) * .7); background: var(--surface); box-shadow: var(--lift-sm); }
.countdown__number { font-family: var(--heading-font); font-size: clamp(2.4rem, 6vw, 4.2rem); line-height: 1; letter-spacing: -.04em; font-variant-numeric: tabular-nums; }
.countdown__label { margin-top: .65rem; font-size: .6rem; letter-spacing: .22em; text-transform: uppercase; opacity: .55; }
/* Until site.js has ticked once the server already rendered the real value, so
   the numbers are correct with JavaScript off and there is no flash of 00:00. */
.countdown__number[data-pending] { opacity: .45; }

/* --------------------------------------------------------------------------
   Agenda
   -------------------------------------------------------------------------- */
.event-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; }
.event-card { padding: 1.6rem 1.5rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.event-card__time { font-size: .66rem; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--accent-ink); }
.event-card h3 { margin: .8rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: 2.1rem; line-height: 1; letter-spacing: -.02em; }
.event-card p { margin: .55rem 0 0; font-size: .94rem; line-height: 1.6; opacity: .7; }
.event-card__venue { margin-top: 1.1rem; padding-top: 1.1rem; border-top: 1px solid var(--line-soft); font-size: .88rem; }
.event-card__venue strong { display: block; }
.event-card__venue p { margin: .2rem 0 0; opacity: .6; font-size: .85rem; }
.event-card__actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: 1rem; }

/* --------------------------------------------------------------------------
   Announcements
   -------------------------------------------------------------------------- */
.announcement-list { display: grid; gap: .6rem; margin-bottom: 2.2rem; }
.announcement {
  display: flex; gap: .9rem; align-items: baseline;
  padding: .95rem 1.15rem; border-radius: calc(var(--radius) * .6);
  background: var(--sand-deep); font-size: .92rem;
}
.announcement__label { flex: none; font-size: .58rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; color: var(--accent-ink); }
/* "Urgente" is the couple's own word for "this must be read before the rest"
   (a time change, where to park). Rendering it in the same weight as an
   ordinary notice throws that away, so it is louder, not quieter. */
.announcement--urgent { background: color-mix(in srgb, var(--accent) 20%, var(--surface)); box-shadow: inset 3px 0 0 var(--accent-ink); }
.announcement--urgent .announcement__label { color: var(--text); }

/* The urgent banner on a guest's own pages. role="alert" so a screen reader
   announces it on load rather than waiting for the tab order to reach it. */
.urgent-banner { display: grid; gap: .6rem; width: min(100% - (var(--gutter) * 2), var(--shell)); margin: 1.5rem auto 0; }
.urgent-banner__item { display: flex; gap: 1rem; align-items: baseline; padding: 1rem 1.2rem; border-radius: calc(var(--radius) * .6); background: var(--sand-deep); box-shadow: inset 3px 0 0 var(--accent-ink); }
.urgent-banner__label { flex: none; font-size: .58rem; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; }
.urgent-banner p { margin: .25rem 0 0; font-size: .9rem; opacity: .75; }

/* --------------------------------------------------------------------------
   Venue
   -------------------------------------------------------------------------- */
.venue { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(2rem, 5vw, 4rem); align-items: center; }
.venue__plate { display: grid; gap: 1.1rem; padding: 1.8rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.venue__row { display: grid; gap: .2rem; }
.venue__label { font-size: .6rem; letter-spacing: .2em; text-transform: uppercase; opacity: .5; }
.venue__value { font-family: var(--heading-font); font-size: 1.5rem; line-height: 1.15; }
.venue__value--address { font-family: var(--body-font); font-size: .95rem; line-height: 1.55; opacity: .75; }

/* --------------------------------------------------------------------------
   Photo strip
   -------------------------------------------------------------------------- */
.strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); gap: 1.2rem; }
.strip figcaption { position: static; margin-top: .8rem; color: inherit; text-shadow: none; opacity: .7; font-size: .84rem; }

/* --------------------------------------------------------------------------
   Dress code / split feature
   -------------------------------------------------------------------------- */
.split-feature { display: grid; grid-template-columns: 1fr 1fr; align-items: stretch; background: var(--sand-deep); }
.split-feature__copy { align-self: center; max-width: 34rem; padding: clamp(2.5rem, 6vw, 5rem) clamp(1.5rem, 5vw, 5rem); }
.split-feature__copy p:last-of-type { max-width: 28rem; font-size: 1.02rem; line-height: 1.7; opacity: .72; }
.split-feature__media { position: relative; min-height: 22rem; }
.split-feature__media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.split-feature__media--empty { display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 12%, transparent); font-size: .88rem; opacity: .6; text-align: center; padding: 2rem; }

/* --------------------------------------------------------------------------
   Gifts
   -------------------------------------------------------------------------- */
.gift-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); gap: 1rem; }
.gift-card { display: flex; flex-direction: column; overflow: hidden; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.gift-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
.gift-card__body { display: flex; flex-direction: column; flex: 1; padding: 1.5rem; }
.gift-card h3 { margin: 0; font-family: var(--heading-font); font-weight: 400; font-size: 1.9rem; line-height: 1.05; letter-spacing: -.02em; }
.gift-card p { white-space: pre-line; opacity: .7; font-size: .94rem; }
.gift-card .text-link, .gift-card .gift-claim { margin-top: auto; padding-top: 1rem; }
/* The "taken" states are quieter than the card, not louder: a guest who has
   already bought something should not have to look at a red badge to feel they
   missed out. */
.gift-card--taken h3 { opacity: .7; }
.gift-state { align-self: flex-start; margin: .85rem 0 0; padding: .45rem .8rem; border-radius: 99px; font-size: .74rem; font-weight: 500; }
.gift-state--claimed { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.gift-state--have { background: color-mix(in srgb, var(--text) 8%, transparent); }
.gift-claim summary { cursor: pointer; font-size: .72rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--accent-ink); }
.gift-claim__form { display: grid; gap: .5rem; margin-top: .8rem; }
.gift-claim__form button { justify-self: start; border: 0; border-radius: 99px; padding: .6rem 1.15rem; background: var(--primary); color: var(--background); font-size: .72rem; font-weight: 600; cursor: pointer; }
.gift-claim__note { margin: .5rem 0 0; font-size: .7rem; opacity: .55; }

/* --------------------------------------------------------------------------
   RSVP
   -------------------------------------------------------------------------- */
/* The one dark band the whole site is built around, and the reason --panel
   exists. --primary is the fill of a solid ACTION at night (the paper, so a
   button is the brightest thing on the page), and a full-bleed panel reading
   from it would be a slab of ivory in the middle of a dark page with cream
   text meant for the opposite of it. */
.rsvp-panel { position: relative; overflow: hidden; padding: clamp(2rem, 6vw, 4.5rem); border-radius: calc(var(--radius) * 1.1); background: var(--panel); color: var(--on-panel); }
/* A soft warm bloom instead of the hard concentric rings that read as a
   rendering artefact. radial-gradient + blur, sized in vmax so it cannot be
   the wrong shape on a phone. */
.rsvp-panel::after {
  content: ""; position: absolute; right: -14rem; top: -16rem;
  width: 34rem; height: 34rem; border-radius: 50%; pointer-events: none;
  background: radial-gradient(circle, color-mix(in srgb, var(--accent) 42%, transparent) 0%, transparent 68%);
  filter: blur(10px);
}
.rsvp-panel > * { position: relative; z-index: 1; }
.rsvp-panel h2 { max-width: 14ch; margin: .8rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: clamp(2.6rem, 6vw, 4.4rem); line-height: .92; letter-spacing: -.04em; }
.rsvp-form { display: flex; flex-wrap: wrap; gap: .6rem; max-width: 40rem; margin-top: 1.8rem; }
.rsvp-form .field { flex: 1 1 16rem; border-radius: 99px; padding: .95rem 1.3rem; }
.rsvp-form__note { flex: 1 0 100%; margin: .2rem 0 0; font-size: .8rem; line-height: 1.5; color: #fff; opacity: .85; }
.rsvp-form__note[hidden] { display: none; }

/* --------------------------------------------------------------------------
   Practical info / travel / FAQ / calendar (shared with the guest pages)
   -------------------------------------------------------------------------- */
.tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); gap: 1rem; }
.tool-card { padding: 1.5rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.tool-card h3 { margin: .6rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: 1.7rem; line-height: 1.05; }
.tool-card p { margin: .45rem 0 0; font-size: .93rem; line-height: 1.6; opacity: .7; }
.calendar-daybar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: 1.2rem 1.4rem; border-radius: var(--radius); background: var(--sand-deep); }
.calendar-daybar strong { display: block; font-family: var(--heading-font); font-weight: 400; font-size: 1.4rem; }
.calendar-daybar small { display: block; margin-top: .2rem; font-size: .78rem; opacity: .6; }
.calendar-daybar .button { margin-left: auto; }
.calendar-list { display: grid; gap: .8rem; margin-top: 1rem; }
.calendar-item { display: grid; gap: .8rem; padding: 1.3rem 1.4rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.calendar-item__when strong { display: block; font-family: var(--heading-font); font-weight: 400; font-size: 1.5rem; }
.calendar-item__when small { display: block; margin-top: .2rem; font-size: .8rem; opacity: .6; }
.calendar-item__actions { display: grid; gap: .5rem; }
.faq-list { display: grid; gap: .6rem; max-width: 52rem; }
.faq-list details { border-radius: calc(var(--radius) * .6); background: var(--card); box-shadow: var(--lift-sm); }
.faq-list summary { cursor: pointer; padding: 1.1rem 1.3rem; font-family: var(--heading-font); font-size: 1.35rem; list-style: none; }
.faq-list summary::-webkit-details-marker { display: none; }
.faq-list summary::after { content: "+"; float: right; color: var(--accent-ink); font-family: var(--body-font); }
.faq-list details[open] summary::after { content: "\2212"; }
.faq-list details p { margin: 0; padding: 0 1.3rem 1.2rem; font-size: .95rem; line-height: 1.7; opacity: .72; }

/* --------------------------------------------------------------------------
   Story chapters
   -------------------------------------------------------------------------- */
.story-chapters { padding-bottom: 3rem; }
.chapter { width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto; display: grid; grid-template-columns: 1fr 1fr; gap: clamp(1.5rem, 5vw, 4.5rem); align-items: center; padding: clamp(2.5rem, 7vw, 5.5rem) 0; }
.chapter--alt .chapter__visual { order: 2; }
.chapter--alt .chapter__copy { order: 1; }
.chapter--narrow { grid-template-columns: minmax(0, 40rem); justify-content: center; text-align: center; }
.chapter--narrow .chapter__copy { max-width: 36rem; margin-inline: auto; }
.chapter__visual { position: relative; border-radius: var(--radius); overflow: hidden; background: var(--surface); min-height: 18rem; box-shadow: var(--lift-lg); }
.chapter__visual img { width: 100%; height: 100%; min-height: 18rem; object-fit: cover; }
.chapter__video { position: relative; width: 100%; padding-top: 56.25%; background: #000; }
.chapter__video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.chapter__copy h3 { margin: .6rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: clamp(2.2rem, 4.2vw, 3.4rem); line-height: .98; letter-spacing: -.035em; }
.chapter__copy p { margin: 1rem 0 0; font-size: 1.02rem; line-height: 1.75; opacity: .72; white-space: pre-line; }

/* --------------------------------------------------------------------------
   After party
   -------------------------------------------------------------------------- */
.after-party { margin: 0 0 var(--section); padding: clamp(3rem, 7vw, 5.5rem) var(--gutter); text-align: center; background: var(--panel); color: var(--on-panel); }
.after-party__inner { max-width: 40rem; margin-inline: auto; }
.after-party h2 { margin: .8rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: clamp(2.4rem, 5.6vw, 4rem); line-height: .94; letter-spacing: -.04em; }
.after-party p { margin: 1.2rem 0 0; opacity: .72; }
.after-party .hero-actions { justify-content: center; }

/* Floating RSVP. Hidden on desktop because the header already carries the
   button, shown on phones where the header has no room for it. It yields to
   the RSVP section itself rather than sitting on top of it. */
.floating-rsvp { position: fixed; z-index: 50; left: var(--gutter); right: var(--gutter); bottom: 1rem; display: none; transition: opacity .25s; }
.floating-rsvp a { display: flex; justify-content: center; box-shadow: var(--lift-lg); }
.floating-rsvp[hidden] { display: none; }

/* --------------------------------------------------------------------------
   Footer
   -------------------------------------------------------------------------- */
.site-footer { border-top: 1px solid var(--line); background: var(--sand); }
.site-footer__inner { display: grid; grid-template-columns: minmax(0, 1.4fr) repeat(auto-fit, minmax(9rem, 1fr)); gap: 2.5rem; width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto; padding: clamp(2.5rem, 5vw, 4rem) 0 2rem; }
.site-footer h2 { margin: 0 0 .9rem; font-size: .6rem; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; opacity: .5; }
.site-footer__links { display: grid; gap: .5rem; align-content: start; }
.site-footer__links a { font-size: .88rem; text-decoration: none; opacity: .72; }
.site-footer__links a:hover { opacity: 1; text-decoration: underline; text-underline-offset: 3px; }
.site-footer__note { margin: 1.4rem 0 0; font-size: .82rem; opacity: .55; }
.site-footer__legal { display: flex; flex-wrap: wrap; gap: .6rem 1.5rem; justify-content: space-between; width: min(100% - (var(--gutter) * 2), var(--shell)); margin-inline: auto; padding: 1.2rem 0 1.6rem; border-top: 1px solid var(--line-soft); font-size: .76rem; opacity: .5; }

/* --------------------------------------------------------------------------
   Flashes
   -------------------------------------------------------------------------- */
.flash-stack { position: fixed; z-index: 90; top: 5rem; right: 1rem; max-width: min(26rem, calc(100% - 2rem)); }
.flash { margin-bottom: .5rem; padding: 1rem 1.15rem; border-radius: calc(var(--radius) * .6); background: var(--surface); box-shadow: var(--lift-lg); font-size: .9rem; }
.flash--error { background: var(--flash-error); color: var(--flash-error-ink); }
.flash--success { background: var(--flash-ok); color: var(--flash-ok-ink); }

/* --------------------------------------------------------------------------
   Guest photo wall

   Three rules, not a class soup. The previous markup was
   `class="aspect-square object-cover rounded-xl w-full"` on every tile, and
   live-wall.js rebuilt that same string from JavaScript to prepend a new photo
   - so the styling of a tile added at runtime and the styling of a tile
   rendered by the server were two independent copies of the same decision.
   They had already drifted once, which is the kind of thing a screenshot of
   the finished page never shows.
   -------------------------------------------------------------------------- */
.photo-wall { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 13rem), 1fr)); gap: .8rem; }
.photo-cell { position: relative; margin: 0; overflow: hidden; border-radius: calc(var(--radius) * .7); background: var(--surface); box-shadow: var(--lift-sm); }
.photo-cell__img { width: 100%; aspect-ratio: 1; object-fit: cover; cursor: zoom-in; transition: transform .5s cubic-bezier(.2, .7, .2, 1); }
.photo-cell:hover .photo-cell__img { transform: scale(1.04); }
.photo-cell__credit {
  position: absolute; inset: auto 0 0 0;
  padding: 1.6rem .8rem .55rem;
  background: linear-gradient(to top, rgba(0, 0, 0, .7), transparent);
  color: #fff; font-size: .7rem; line-height: 1.25;
  pointer-events: none;
}
/* Set by live-wall.js on a freshly prepended tile, removed on the next frame,
   so a new photo fades in rather than appearing. */
.photo-cell.is-arriving { opacity: 0; transition: opacity .7s ease; }

/* The name list on a guest's own table. A guest looking for their own name in
   a wall of similar ones needs it findable, hence the ring. */
.roster { display: grid; margin: 0; padding: 0; list-style: none; }
.roster__row { display: flex; align-items: center; gap: .8rem; padding: .7rem 0; border-bottom: 1px solid var(--line-soft); }
.roster__row:last-child { border-bottom: 0; }
.roster__name { font-size: 1.05rem; }
.roster__you { margin-left: auto; padding: .2rem .6rem; border-radius: 99px; background: color-mix(in srgb, var(--accent) 20%, transparent); font-size: .64rem; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }

/* --------------------------------------------------------------------------
   Seating board (read-only, guest view)

   The table chips are positioned with inline left/top percentages, which is
   computed per table - the reason style-src-attr allows 'unsafe-inline' in the
   CSP. The positions come from the same columns the admin drags, and
   services.seated_tables_for() is the single definition of "who sits here",
   so the guest's board cannot disagree with the couple's.
   -------------------------------------------------------------------------- */
.seating-board {
  position: relative;
  height: min(70vh, 34rem);
  min-height: 26rem;
  overflow: hidden;
  border-radius: var(--radius);
  background:
    radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--accent) 9%, var(--surface)), var(--surface));
  box-shadow: inset 0 0 0 1px var(--line);
}
.seating-board__table {
  position: absolute;
  display: grid; place-items: center; text-align: center;
  width: 6rem; height: 4.4rem; padding: .3rem;
  transform: translate(-50%, -50%);
  border-radius: calc(var(--radius) * .5);
  background: var(--sand-deep);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: .8rem; line-height: 1.2;
}
.seating-board__table.is-round { border-radius: 999px; width: 5.5rem; height: 5.5rem; }
/* The guest's own table. Colour plus a ring, not colour alone. */
.seating-board__table.is-yours {
  background: var(--primary); color: var(--background);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent);
  font-weight: 600;
}

/* --------------------------------------------------------------------------
   Guestbook
   -------------------------------------------------------------------------- */
.guestbook { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: 1.5rem; align-items: start; }
.guestbook form { position: sticky; top: 5.5rem; }
.guestbook__entries { display: grid; gap: 1rem; }
.guestbook__entries p { margin: 0; }

/* --------------------------------------------------------------------------
   Themed helpers used by the guest pages
   -------------------------------------------------------------------------- */
.themed-surface { background: var(--surface); }
.themed-primary { background: var(--primary); color: var(--background); }
.themed-accent { color: var(--accent-ink); }

/* --------------------------------------------------------------------------
   Personal invitation page
   -------------------------------------------------------------------------- */
.invite-hero { position: relative; padding: clamp(3rem, 8vw, 6rem) var(--gutter); text-align: center; background: radial-gradient(circle at 78% 22%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 55%), var(--sand); }
.invite-hero__inner { max-width: 46rem; margin-inline: auto; }
.invite-hero h1 { margin: 1rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: clamp(3rem, 8vw, 5.4rem); line-height: .9; letter-spacing: -.045em; }
.invite-hero__inner > p:not(.eyebrow) { max-width: 32rem; margin: 1.6rem auto 0; font-size: 1.08rem; line-height: 1.7; opacity: .7; }
.invite-hero .hero-actions { justify-content: center; }
.invite-details { width: min(100% - (var(--gutter) * 2), 68rem); margin-inline: auto; padding: 4.5rem 0; }
.invite-events { display: grid; gap: 1rem; }
.invite-event { padding: 1.6rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.invite-event h3 { margin: .4rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: 2.1rem; line-height: 1; letter-spacing: -.02em; }
.invite-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr)); gap: 1rem; margin-top: 1rem; }
.invite-card { padding: 1.7rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.invite-card h2 { margin: .5rem 0 0; font-family: var(--heading-font); font-weight: 400; font-size: 2.4rem; line-height: .98; letter-spacing: -.03em; }
.guest-status { display: grid; gap: .5rem; }
.guest-status__item { display: flex; justify-content: space-between; gap: 1rem; padding: .8rem 0; border-bottom: 1px solid var(--line-soft); font-size: .94rem; }
.guest-status__item:last-child { border-bottom: 0; }

/* Summary strip: the three facts about THIS guest, above the fold. */
.invite-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); gap: 1rem; width: min(100% - (var(--gutter) * 2), 68rem); margin: -2.5rem auto 0; position: relative; z-index: 2; }
.invite-summary__cell { padding: 1.3rem 1.4rem; border-radius: var(--radius); background: var(--card); box-shadow: var(--lift); }
.invite-summary__label { font-size: .58rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; opacity: .5; }
.invite-summary__value { margin: .5rem 0 0; font-family: var(--heading-font); font-size: 1.6rem; line-height: 1.1; }
.invite-summary__value--sm { font-family: var(--body-font); font-size: .92rem; line-height: 1.5; opacity: .75; }

/* --------------------------------------------------------------------------
   Envelope

   The only place in the file with real hardcoded colours, and they stay that
   way in both themes. The envelope is not a surface of the page, it is a
   photographed object: a cream envelope with a red wax seal, lit from the
   left. Recolouring it to the night palette would make it a black shape on a
   black background, which is the one thing an object with a lit side cannot
   be. The paper around it is what follows the theme.
   -------------------------------------------------------------------------- */
.envelope-overlay { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; overflow: hidden; background: radial-gradient(circle at 50% 33%, color-mix(in srgb, var(--surface) 90%, #fff), var(--sand) 65%); transition: opacity .55s ease, visibility .55s ease; }
.envelope-overlay::before, .envelope-overlay::after { content: ""; position: absolute; width: 42vmax; aspect-ratio: 1; border-radius: 50%; filter: blur(10px); opacity: .4; }
.envelope-overlay::before { left: -21vmax; top: -22vmax; background: color-mix(in srgb, var(--accent) 18%, transparent); }
.envelope-overlay::after { right: -22vmax; bottom: -23vmax; background: color-mix(in srgb, var(--primary) 12%, transparent); }
.envelope-overlay.is-closing { opacity: 0; visibility: hidden; pointer-events: none; }
/* The closed-and-dismissed state. The overlay is hidden rather than removed from
   the document, which is what lets the replay button bring it back: replaying is
   then just removing this class. `display: none` takes the node out of the tab
   order and out of the accessibility tree, so leaving it in place costs nothing. */
.envelope-overlay.is-hidden { display: none; }
.envelope-stage { position: relative; z-index: 1; display: grid; place-items: center; width: 100%; height: 100%; perspective: 1400px; }
.envelope-wrap { position: relative; z-index: 1; width: min(88vw, 440px); padding-bottom: 2.5rem; text-align: center; }
.envelope { position: relative; width: min(84vw, 380px); height: 255px; margin: auto; cursor: pointer; touch-action: manipulation; transform-style: preserve-3d; animation: envelope-float 4s ease-in-out infinite; }
.envelope:focus-visible { outline: 3px solid var(--accent-ink); outline-offset: 12px; border-radius: 1.25rem; }
.env-shadow { position: absolute; left: 10%; right: 10%; bottom: -17px; height: 22px; border-radius: 50%; background: rgba(35, 25, 18, .18); filter: blur(13px); transition: transform 1s ease, opacity 1s ease; }
.env-body { position: absolute; inset: 24px 0 0; overflow: hidden; border: 1px solid rgba(94, 71, 52, .14); border-radius: 1.15rem; background: linear-gradient(145deg, #f5ecdf, #dfcbb5); box-shadow: 0 28px 70px rgba(31, 26, 22, .2); }
.env-left, .env-right, .env-fold { position: absolute; pointer-events: none; }
.env-left, .env-right { top: 24px; width: 50%; height: 230px; z-index: 3; }
.env-left { left: 0; clip-path: polygon(0 0, 100% 50%, 0 100%); background: linear-gradient(145deg, #ecdfd0, #dbc5ad); }
.env-right { right: 0; clip-path: polygon(100% 0, 0 50%, 100% 100%); background: linear-gradient(215deg, #f0e3d4, #d7c0a7); }
.env-fold { left: 0; right: 0; bottom: 0; height: 120px; z-index: 4; clip-path: polygon(0 100%, 50% 8%, 100% 100%); background: linear-gradient(180deg, #e8d8c7, #d4bda4); }
/* color, not inherited.
 *
 * This is the .replay bug again, and it was introduced by the dark theme: the
 * card below is a FIXED cream gradient in both themes, so its ink must be fixed
 * too. Left to inherit, the text took --text from the page, which the night
 * theme turns near-white - light ink on cream paper, measured at 1.17:1. The
 * name and the date on the invitation card became unreadable at night, while
 * every contrast report still passed, because those reports measure the
 * tokens and not this pairing.
 *
 * The rule: a rule that sets a background the theme cannot change must set the
 * colour that goes on it. See tests/landing.test.py section 16. */
.letter { position: absolute; z-index: 2; left: 7%; right: 7%; bottom: 10px; height: 180px; padding: 28px 20px; border-radius: .65rem; background: linear-gradient(180deg, #fffefa, #f8f0e7); color: var(--letter-ink); box-shadow: 0 16px 34px rgba(31, 26, 22, .12); transform: translateY(74px); transition: transform .9s cubic-bezier(.16, 1, .3, 1) .2s; }
.letter-rule { width: 3.25rem; height: 1px; margin: 0 auto 1rem; background: color-mix(in srgb, var(--accent) 64%, transparent); }
.letter-rule-bottom { width: 2rem; margin-top: 1.1rem; }
.env-title { font-family: var(--heading-font); font-size: 2.1rem; line-height: .94; }
.env-sub { font-size: .62rem; letter-spacing: .27em; text-transform: uppercase; color: var(--letter-ink-soft); }
.env-flap { position: absolute; top: 24px; left: 0; width: 100%; height: 130px; z-index: 6; clip-path: polygon(0 0, 50% 100%, 100% 0); transform-origin: 50% 0; transform-style: preserve-3d; backface-visibility: hidden; background: linear-gradient(180deg, #d9c5ad, #c7af94); filter: drop-shadow(0 5px 5px rgba(50, 40, 30, .1)); transition: transform .82s cubic-bezier(.16, 1, .3, 1); }
.wax-seal { position: absolute; z-index: 8; left: 50%; top: 62%; width: 3.65rem; height: 3.65rem; display: grid; place-items: center; border-radius: 50%; transform: translate(-50%, -50%); background: radial-gradient(circle at 35% 27%, #d59e89, #99564c 72%); color: #ffe9df; box-shadow: 0 7px 18px rgba(83, 44, 39, .28), inset 0 2px 5px rgba(255, 255, 255, .3); font-size: 1.4rem; transition: transform .65s cubic-bezier(.16, 1, .3, 1), opacity .35s ease; }
.envelope-hint { margin-top: 1.5rem; font-size: .65rem; letter-spacing: .25em; text-transform: uppercase; opacity: .65; animation: hint-pulse 2.2s ease-in-out infinite; }
.env-skip { margin-top: .8rem; padding: .4rem .65rem; border: 0; background: transparent; color: inherit; font: inherit; font-size: .72rem; text-decoration: underline; opacity: .58; cursor: pointer; }
.envelope-overlay.is-opening .envelope { animation: none; }
.envelope-overlay.is-opening .env-flap { transform: rotateX(-178deg); }
.envelope-overlay.is-opening .letter { transform: translateY(-86px); }
.envelope-overlay.is-opening .wax-seal { transform: translate(-50%, -50%) scale(.1) rotate(-24deg); opacity: 0; }
.envelope-overlay.is-opening .env-shadow { transform: scale(.9); opacity: .55; }
@keyframes envelope-float { 0%, 100% { transform: translateY(0) rotate(.1deg); } 50% { transform: translateY(-7px) rotate(-.12deg); } }
@keyframes hint-pulse { 0%, 100% { opacity: .42; } 50% { opacity: .9; } }

/* --------------------------------------------------------------------------
   Replay the envelope

   Two copies of one control: a pill in the hero, and a quiet link in the footer.
   Both are hidden in the markup and revealed by site.js only once the animation
   has been seen.

   The hero copy is positioned absolutely against .hero, NOT fixed to the
   viewport. That is deliberate. A fixed control is always in the way of
   something: the sections carry 3.75rem-6.5rem of top padding, so a pill pinned
   under the sticky header eventually lands on top of a section heading, and no
   amount of care in the styling fixes that - it is arithmetic. Anchored to the
   hero it scrolls away with the invitation it belongs to, cannot overlap
   anything, and needs no scroll listener to get out of the way.

   The hero's own top padding is at least 4rem, so the pill sits inside padding
   and never on top of the eyebrow or the name.
   -------------------------------------------------------------------------- */
.replay {
  position: absolute; z-index: 3; top: .85rem; left: var(--gutter);
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .3rem .7rem; border-radius: 99px;
  border: 1px solid var(--line);
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  backdrop-filter: blur(10px);
  box-shadow: var(--lift-sm);
  font-size: .62rem; font-weight: 600; letter-spacing: .09em; text-transform: uppercase;
  /* --text explicitly, never `inherit`. This control sits over a photograph, and
     inheriting means its colour is decided by whatever it happens to be mounted
     inside - the same class of bug that made the RSVP link input invisible. */
  color: var(--text); cursor: pointer;
  opacity: .82;
  transition: opacity .2s, transform .2s, box-shadow .2s;
}
.replay:hover { opacity: 1; transform: translateY(-1px); box-shadow: var(--lift); }
.replay[hidden] { display: none; }
.replay__icon { font-size: .88rem; line-height: 1; }
/* The ring spins once on hover, which is the whole "this replays something"
   signal without needing the label to say so. */
.replay:hover .replay__icon { animation: replay-spin 1.1s cubic-bezier(.4, 0, .2, 1); }
@keyframes replay-spin { to { transform: rotate(-360deg); } }

/* The footer copy: one more line among the other links, no pill.
   The size and opacity are restated here rather than left to inherit because
   .site-footer__links styles `a` specifically - a button left to the cascade
   would render at the default 1rem and full strength, standing out from the
   links it is supposed to look like one of. */
.footer-replay {
  display: block; padding: 0; border: 0; background: none;
  font-family: inherit; font-size: .88rem; line-height: inherit;
  color: inherit; text-align: left; text-decoration: none; cursor: pointer;
  opacity: .72; transition: opacity .2s;
}
.footer-replay[hidden] { display: none; }
.footer-replay:hover { opacity: 1; text-decoration: underline; text-underline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .replay:hover .replay__icon { animation: none; }
  .replay, .footer-replay { transition: none; }
  .replay:hover { transform: none; }
}

/* --------------------------------------------------------------------------
   Scroll reveal start state

   Applied by site.js immediately before it hands the elements to GSAP, and
   removed by the animation's onComplete, by a 2.5s backstop, or by the catch
   around the whole thing. The point is what is NOT here: an `opacity: 0` that
   ships in the markup. A reveal that hides content by default makes the whole
   page conditional on a script arriving, and on this site the elements it
   targeted were the agenda, the dress code and the gift list.
   -------------------------------------------------------------------------- */
.will-reveal { opacity: 0; will-change: transform, opacity; }

/* --------------------------------------------------------------------------
   Reduced motion
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .envelope-overlay { display: none; }
  .envelope, .envelope-hint { animation: none; }
  .button, .chip, .site-nav a { transition: none; }
  .button:hover, a.chip:hover { transform: none; }
  /* Belt and braces: site.js bails out early on this query, so the class is
     never added - but if it were, the page would be blank. */
  .will-reveal { opacity: 1; }
}

/* --------------------------------------------------------------------------
   Small screens
   -------------------------------------------------------------------------- */
@media (max-width: 60rem) {
  :root { --section: 4.5rem; }
  .section-heading, .venue, .hero--split .hero__inner, .split-feature { grid-template-columns: 1fr; gap: 1.75rem; }
  .section-heading__copy { padding-top: 0; }
  .chapter { grid-template-columns: 1fr; gap: 1.5rem; }
  .chapter--alt .chapter__visual, .chapter--alt .chapter__copy { order: initial; }
  .chapter--narrow { text-align: left; }
  .chapter--narrow .chapter__copy { margin-inline: 0; }
  .split-feature__copy { padding: 3rem var(--gutter); }
  .split-feature__media { min-height: 18rem; }
  .site-footer__inner { grid-template-columns: 1fr 1fr; }
}

@media (max-width: 46rem) {
  :root { --gutter: 1.15rem; --section: 3.75rem; }
  .site-header__inner { grid-template-columns: 1fr auto; }
  .site-nav { display: none; }
  .header-rsvp { display: none; }
  .nav-toggle { display: inline-flex; }
  .display-title { font-size: clamp(2.9rem, 15vw, 4.4rem); max-width: 10ch; }
  .countdown { gap: .5rem; }
  .countdown > div { padding: 1rem .3rem; }
  .countdown__label { font-size: .52rem; letter-spacing: .14em; }
  .hero-actions .button { flex: 1 1 auto; }
  .floating-rsvp { display: block; }
  /* Room for the floating bar, which is fixed to the bottom of the viewport
     and would otherwise sit on top of the last thing on the page. */
  .home-main { padding-bottom: 5.5rem; }
  .plate--stack { flex-direction: column; align-items: flex-start; gap: .5rem; }
  .site-footer__inner { grid-template-columns: 1fr; gap: 1.75rem; }
  .site-footer__legal { justify-content: flex-start; }
  .envelope { height: 240px; }
  .letter { height: 168px; }
  .env-title { font-size: 1.8rem; }
}
