/* ==========================================================================
   PANAYIOTIS & TEO — styles.css

     1. Tokens
     2. Reset
     3. Typography & section furniture
     4. Section scaffolding
     5. Nav + language toggle
     6. Audio toggle
     7. Hero
     8. Countdown
     9. The Day — timeline
    10. Guest guide
    11. RSVP form + the popup that holds it
    12. Footer
    13. Scroll reveals
    14. Reduced motion
    15. Entrance — the envelope overlay

   §15 is LAST on purpose, not appended carelessly. It is the only thing on the
   page that paints above everything else, and it needs §14's no-motion
   contracts already declared so its own overrides can sit after them at equal
   specificity. Every other section keeps the number it has always had — the
   cross-references in these comments and in js/main.js are load-bearing.

   PHASE 07 owns all decoration: torn edges, script headers, flourishes, card
   borders. Everything below is deliberately plain — resist decorating it. The
   hairlines that are here are structural (they separate things); they are not
   ornament, and phase 07 is what turns them into it.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. TOKENS

   The first block is normative — it matches Prompts/00-constitution.md and
   nothing may change it without changing that file too. The second block is
   derived from it: every value there is one of the five, at a stated alpha,
   with the resulting contrast computed rather than guessed.
   -------------------------------------------------------------------------- */

:root {
  --paper:     #FAF5EC;   /* warm cream ground */
  --paper-alt: #FFFDF8;   /* lift for alternating sections/cards */
  --ink:       #3A2E25;   /* warm brown-ink — primary text, 12.1:1 on paper */
  --brass:     #A8853F;   /* gold — 3.2:1, LARGE display/script ONLY */
  --brass-dim: #7E6224;   /* 5.3:1 — hairlines, small labels, rules */

  --dur:  1.2s;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);

  /* ---- derived ---- */

  /* Secondary text — --ink at 80%. Composited over --paper that is #605646,
     which measures 6.7:1. It clears AA at any size. Do NOT stack further
     opacity on top of it; the next step down fails. */
  --ink-soft:  rgb(58 46 37 / 0.8);

  /* Structural hairlines — --brass-dim at low alpha. Decorative separators,
     not text and not a control boundary, so the 4.5:1 rule does not reach
     them. Anything a guest must *read* uses --brass-dim at full strength. */
  --rule:        rgb(126 98 36 / 0.16);
  --rule-strong: rgb(126 98 36 / 0.34);

  /* Form errors. Warm brick, chosen to sit inside the palette rather than
     against it. 7.0:1 on --paper. Colour is never the only signal — every
     error also has text and aria-invalid (§11). */
  --error: #8C3A2B;

  /* Interaction feedback is FAST. --dur is the scroll-reveal tempo (slow, so
     nothing snaps); a floating label or a button hover at 1.2s feels broken.
     Two different jobs, two different durations. */
  --dur-ui: 0.2s;

  /* Type stacks. UNIFIED AT PHASE 07 — one face per role, both languages.
     Jost and Cormorant Garamond have no Greek subset, so ΕΛ used to fall
     through to Manrope/EB Garamond and the two languages read as two
     different designs. Worse, a Latin name inside a Greek sentence mixed
     two serifs mid-line. EB Garamond and Manrope both carry U+0370–03FF,
     so they now serve Latin and Greek alike and that mixing is gone. */
  --font-body:    "Manrope", system-ui, -apple-system, sans-serif;
  --font-display: "EB Garamond", Georgia, serif;

  /* ⚠️ LATIN-ONLY. Pinyon Script has no Greek subset — NO calligraphic face
     on Google Fonts does (checked 34; the ones that appear to are shipping
     greek-ext, U+1F00–1FFF, which is ancient polytonic and contains no
     modern Greek letters). So --font-script may ONLY be used on strings
     that never translate: the P & T monogram and the hero ampersand.
     Put it on a section header and ΕΛ silently falls back to the serif. */
  --font-script:  "Pinyon Script", "EB Garamond", cursive;

  /* Layout. Overridden at the two breakpoints in §4. */
  --pad-x:     1.5rem;
  --section-y: 4.5rem;
  --maxw:      68rem;

  /* Wide-tracked uppercase labels. */
  --track-label: 0.18em;

  /* Minimum comfortable tap target. 44px is the floor, not the target. */
  --tap: 2.75rem;
}

/* Greek tracking. The token block promised this at phase 03 "once there is
   real Greek to measure" — measured at phase 07, now that both languages sit
   in Manrope and the comparison is finally like-for-like.

   Greek uppercase sets intrinsically wider than Latin, so 0.18em that reads as
   elegant in "COUNTING DOWN" reads as loose in "ΑΝΤΙΣΤΡΟΦΗ ΜΕΤΡΗΣΗ". 0.14em
   matches the Latin colour.

   What this does NOT fix, deliberately: "Δευτερόλεπτα" is 80.7px untracked in
   the 79.5px column a 390px phone gives it, so it overflows at ZERO tracking
   and no letter-spacing value can save it. It wraps to two lines, which is
   what .count-unit-label's overflow-wrap/hyphens are already there for. The
   alternative was shrinking Greek labels to ~9.5px, which is worse. */
:root:lang(el) {
  --track-label: 0.14em;
}

/* --------------------------------------------------------------------------
   2. RESET
   -------------------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  /* The nav is fixed, so the hero's scroll cue would land #countdown
     underneath it. Measured at 63px; the extra is breathing room. */
  scroll-padding-top: 5rem;
  /* For the one in-page anchor. This is CSS-native smooth scroll, not
     scroll hijacking — constitution §5 rules out Lenis and friends, which
     replace the scroller; this only eases a jump the browser already makes.
     §14 turns it off under reduced motion. */
  scroll-behavior: smooth;
}

body {
  margin: 0;
  overflow-x: hidden;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: 300;
  font-size: 1rem;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
}

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

fieldset {
  padding: 0;
  border: 0;
  min-width: 0;    /* fieldset defaults to min-content and breaks grid/flex */
}

legend {
  padding: 0;
}

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

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

a {
  color: inherit;
}

:focus-visible {
  outline: 2px solid var(--brass-dim);
  outline-offset: 3px;
}

[hidden] {
  display: none !important;
}

/* Screen-reader-only, and the honeypot's hiding technique (§11). Kept out of
   the layout entirely rather than pushed off-screen with a big negative
   offset, which can extend the scrollable area. */
.visually-hidden,
.hp-field {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --------------------------------------------------------------------------
   3. TYPOGRAPHY & SECTION FURNITURE
   -------------------------------------------------------------------------- */

.section-head {
  text-align: center;
}

.section-label {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  font-family: var(--font-body);
  font-size: 0.75rem;
  font-weight: 400;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  /* --brass-dim at FULL opacity. --brass fails AA at this size, and adding
     opacity to --brass-dim drops it below AA too. Do not tint this. */
  color: var(--brass-dim);
}

/* FLANKING SWASHES. Until phase 07 these were 1px bars; the markup hooks were
   left in place so the swap could be CSS-only, and it was.

   A hairline that tapers away at the outer end and resolves into a small open
   curl at the inner end, next to the label. Drawn rather than borrowed, in the
   same idiom as the six timeline icons: single weight, round caps, no fill.

   THE SWASH IS NOT A SCRIPT FACE, AND THAT IS THE POINT. Section labels
   translate — "The Day" becomes "Το Πρόγραμμα" — so they cannot carry Pinyon
   Script, which has no Greek. Putting the ornament in the SVG beside the words
   instead of in the letterforms is what lets both languages have it.

   --brass-dim (#7E6224) is baked into the data URI because a data URI cannot
   read a custom property. If the palette moves, these two strings move with
   it — they are the only place the hex is duplicated.

   Anchored `right center` so that when flex shrinks the swash on a narrow
   phone, the curl stays tucked against the label and the plain tail is what
   gets eaten. */
.hairline {
  flex: 0 1 3.5rem;
  height: 10px;
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='12' viewBox='0 0 56 12' fill='none' stroke='%237E6224' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 6h34' stroke-width='.9' opacity='.5'/%3E%3Cpath d='M35 6c4-3.5 10-3.9 14.4 0-4.4 3.9-10.4 3.5-14.4 0z' stroke-width='1'/%3E%3Cpath d='M38 6h8.4' stroke-width='.7' opacity='.55'/%3E%3C/svg%3E")
    right center / contain no-repeat;
}

/* The trailing swash is the same drawing, mirrored, so the pair reads as one
   symmetrical ornament rather than two copies pointing the same way. */
.section-label .hairline:last-child {
  transform: scaleX(-1);
}

.section-intro {
  max-width: 46ch;
  margin: 1.75rem auto 0;
  text-align: center;
  color: var(--ink-soft);
}

/* CENTRED FLOURISH. A 1px bar until phase 07.

   Two hairlines running out from a cyclamen LEAF at the centre — the same
   plant that flowers down the timeline (§9), so the ornament vocabulary is one
   species and not an assortment.

   LEAVES, NOT THE FLOWER, AND FOR A MEASURED REASON. A cyclamen flower is
   defined by reflexed petals swept back off a nodding head, and at the ~22px
   this divider gets, that silhouette collapses into a bead with three
   antennae — drawn, inspected at 4x, and rejected. The flower keeps its
   56x72px slot on the timeline, where the petals have room to read.

   A single cordate leaf was the next attempt and was rejected too: cordate
   means heart-shaped, so upright and symmetrical it stops reading as a leaf
   and starts reading as ♥ — the one motif a wedding page should be most
   careful with. Two leaves mirrored about a bud keep the symmetry a centred
   divider needs while being unmistakably vegetal. They are the same leaf the
   header swashes end in, so the two ornaments share a vocabulary.

   CONTRAST. This is the one ornament that sits over the hero artwork, where
   phase 05 measured --brass at 2.67:1. It is decorative, not text, so the
   4.5:1 rule does not reach it — and it is still *more* visible than the bar
   it replaces, which was --rule-strong (--brass-dim at 0.34). The centre motif
   is full-strength --brass-dim and only the tails are faded. */
.ornament-rule {
  display: block;
  width: 7rem;
  height: 26px;
  margin: 1.5rem auto;
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='112' height='26' viewBox='0 0 112 26' fill='none' stroke='%237E6224' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 13h36M110 13H74' stroke-width='.9' opacity='.45'/%3E%3Cpath d='M38 13c4-3.5 10-3.9 14.4 0-4.4 3.9-10.4 3.5-14.4 0z' stroke-width='1'/%3E%3Cpath d='M74 13c-4-3.5-10-3.9-14.4 0 4.4 3.9 10.4 3.5 14.4 0z' stroke-width='1'/%3E%3Cpath d='M41 13h8.4M71 13h-8.4' stroke-width='.7' opacity='.55'/%3E%3Ccircle cx='56' cy='13' r='2.2' stroke-width='1'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

/* --------------------------------------------------------------------------
   4. SECTION SCAFFOLDING

   The two creams are two steps apart and will read as one colour on a phone in
   sunlight. The rhythm is carried by spacing and the hairline first; the tone
   shift is a bonus, never the signal.
   -------------------------------------------------------------------------- */

.section {
  padding: var(--section-y) var(--pad-x);
}

/* TORN-PAPER EDGE — replaces the 1px hairline that stood here until phase 07.

   HOW IT WORKS. Each section paints a short strip of its OWN paper colour
   (--ground) above its top edge, overlapping the section it follows, and masks
   that strip with a deckle. The lower section's cream therefore intrudes
   upwards into the one above along an irregular line, which is what a torn
   sheet laid on another sheet actually looks like. Nothing is drawn in a
   third colour and there is no line — the tear IS the boundary between the
   two creams.

   WHY A MASK AND NOT A FILTER. The deckle is generated by feTurbulence +
   feDisplacementMap, but it is baked into a data-URI mask rather than applied
   as a live CSS filter. A live SVG filter on a full-bleed element is
   re-composited on every paint, which is exactly the cost this site should not
   pay on a mid-range phone. As a mask the browser rasterises each unique URI
   once.

   WHY IT STRETCHES INSTEAD OF REPEATING. mask-size is 100%, so one tile spans
   the section's whole width. Repeating a 1200px tile would put a visible seam
   every 1200px on a wide screen; stretching noise is undetectable because
   there is no structure to distort.

   FIVE SEEDS, ONE PER TORN EDGE. Identical edges read as a manufactured
   texture rather than torn paper, so every seed differs. Only five sections
   take an edge (see the assignments below), so five is the whole set. */
:root {
  --deckle-h: 18px;
  --deckle-1: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='18' viewBox='0 0 1200 18' preserveAspectRatio='none'%3E%3Cfilter id='d' x='-10%25' y='-100%25' width='120%25' height='300%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.009 0.42' numOctaves='4' seed='3' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='8' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3Crect x='-20' y='9' width='1240' height='30' fill='%23000' filter='url(%23d)'/%3E%3C/svg%3E");
  --deckle-2: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='18' viewBox='0 0 1200 18' preserveAspectRatio='none'%3E%3Cfilter id='d' x='-10%25' y='-100%25' width='120%25' height='300%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.011 0.38' numOctaves='4' seed='17' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='9' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3Crect x='-20' y='9' width='1240' height='30' fill='%23000' filter='url(%23d)'/%3E%3C/svg%3E");
  --deckle-3: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='18' viewBox='0 0 1200 18' preserveAspectRatio='none'%3E%3Cfilter id='d' x='-10%25' y='-100%25' width='120%25' height='300%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.008 0.5' numOctaves='3' seed='42' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='7' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3Crect x='-20' y='9' width='1240' height='30' fill='%23000' filter='url(%23d)'/%3E%3C/svg%3E");
  --deckle-4: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='18' viewBox='0 0 1200 18' preserveAspectRatio='none'%3E%3Cfilter id='d' x='-10%25' y='-100%25' width='120%25' height='300%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.013 0.45' numOctaves='4' seed='68' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='9' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3Crect x='-20' y='9' width='1240' height='30' fill='%23000' filter='url(%23d)'/%3E%3C/svg%3E");
  --deckle-5: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='18' viewBox='0 0 1200 18' preserveAspectRatio='none'%3E%3Cfilter id='d' x='-10%25' y='-100%25' width='120%25' height='300%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.010 0.35' numOctaves='4' seed='91' result='n'/%3E%3CfeDisplacementMap in='SourceGraphic' in2='n' scale='8' xChannelSelector='R' yChannelSelector='G'/%3E%3C/filter%3E%3Crect x='-20' y='9' width='1240' height='30' fill='%23000' filter='url(%23d)'/%3E%3C/svg%3E");
}

.section + .section {
  position: relative;
}

.section + .section::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  /* +1px so subpixel rounding can never open a hairline of the section
     below between the strip and the section's own top edge. */
  top: calc(1px - var(--deckle-h));
  height: var(--deckle-h);
  background: var(--ground);
  -webkit-mask-image: var(--deckle, var(--deckle-1));
          mask-image: var(--deckle, var(--deckle-1));
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;

  /* The shadow is what makes it read as TORN rather than as a wobbly line.
     --paper and --paper-alt are two steps apart, so the tear on its own is
     almost invisible; a sheet lying on another sheet is legible because of the
     shadow under its edge, not because of the colour difference.

     Cast upward (0 -1px) because the lower section is the sheet on top here —
     it is its torn edge intruding into the section above. drop-shadow, not
     box-shadow, because only drop-shadow follows the mask's alpha; box-shadow
     would trace the strip's rectangle and draw a straight line, which is the
     exact thing being removed.

     0.06 alpha. This is a seam, not a border: it should be felt at a glance
     and invisible when looked at directly. */
  filter: drop-shadow(0 -1px 1px rgb(58 46 37 / 0.06));
  pointer-events: none;
}

/* One seed each. Assigned by section rather than :nth-of-type so that
   reordering the page cannot silently pair two identical edges. */
#countdown        { --deckle: var(--deckle-1); }
.photo-band--1    { --deckle: var(--deckle-2); }
#rsvp             { --deckle: var(--deckle-3); }
.photo-band--2    { --deckle: var(--deckle-4); }
.photo-band--3    { --deckle: var(--deckle-5); }

.section-inner {
  max-width: var(--maxw);
  margin-inline: auto;
}

/* A form is read one line at a time; 68rem of underline is a horizon. */
.section-inner--narrow {
  max-width: 34rem;
}

/* --ground is the section's own paper colour, exposed as a variable so the
   torn edge below can paint itself in it. Keep the two in step. */
.section-paper { --ground: var(--paper); }
.section-alt   { --ground: var(--paper-alt); }
.section-paper,
.section-alt   { background: var(--ground); }

@media (min-width: 768px) {
  :root {
    --pad-x:     3rem;
    --section-y: 7rem;
  }
}

@media (min-width: 1200px) {
  :root {
    --pad-x:     4rem;
    --section-y: 9rem;
  }
}

/* --------------------------------------------------------------------------
   5. NAV + LANGUAGE TOGGLE
   -------------------------------------------------------------------------- */

.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.5rem var(--pad-x);
  background: rgb(250 245 236 / 0.9);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--rule);
}

/* Script, and --ink rather than gold. The nav ground is only 90% --paper with
   a blur behind it, so whatever scrolls under it bleeds through — the hero
   artwork included, where --brass measured 2.67:1 at phase 05. --ink holds at
   any backdrop. Gold is used on the other two monograms, which sit on solid
   ground.
   1.5rem, up from 1.125rem: script needs the size back. letter-spacing is
   removed outright — Pinyon Script is a joining face and tracking snaps the
   strokes between letters. The spaces in "P & T" do the separating. */
.nav-monogram {
  font-family: var(--font-script);
  font-size: 1.5rem;
  line-height: 1;
  color: var(--ink);
}

.lang-toggle {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  font-size: 0.75rem;
  letter-spacing: 0.12em;
}

.lang-btn {
  /* 44px tall and wide enough to hit with a thumb. The label is two
     characters, so without this it is a ~20px target. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--tap);
  min-height: var(--tap);
  color: var(--ink);
  opacity: 0.5;
  transition: opacity var(--dur-ui) ease, color var(--dur-ui) ease;
}

.lang-btn.is-active {
  opacity: 1;
  color: var(--brass-dim);
}

.lang-sep {
  opacity: 0.3;
}

/* --------------------------------------------------------------------------
   6. AUDIO TOGGLE — fixed bottom-left. Three static bars for now; PHASE 07
   gives it its animated state.
   -------------------------------------------------------------------------- */

.audio-toggle {
  position: fixed;
  z-index: 20;
  left: var(--pad-x);
  bottom: 1.5rem;
  display: flex;
  align-items: flex-end;
  gap: 3px;
  width: var(--tap);
  height: var(--tap);
  padding: 0.75rem;
  justify-content: center;
}

.eq-bar {
  width: 2px;
  height: 100%;
  background: var(--brass-dim);
}

.eq-bar:nth-child(1) { height: 60%; }
.eq-bar:nth-child(3) { height: 45%; }

.audio-toggle.is-muted .eq-bar {
  height: 30%;
  opacity: 0.4;
}

/* --------------------------------------------------------------------------
   7. HERO

   Two grid rows: the names take the free row and centre in it, the scroll cue
   takes the auto row and sits at the bottom. No absolute positioning, so a
   long Greek eyebrow can push the block taller without the cue landing on top
   of it.

   The media stack is three layers, bottom to top:
     .hero-media   full-bleed, painted --paper — this is what shows AROUND the
                   desktop card, and behind everything if nothing else loads
     .hero-frame   the artwork itself: the poster as a background-image, with
                   the <video> appended into it by main.js §2
     .hero-scrim   cream wash that buys the text its contrast
   -------------------------------------------------------------------------- */

.hero {
  position: relative;
  display: grid;
  grid-template-rows: 1fr auto;
  justify-items: center;
  min-height: 100svh;   /* NOT 100vh — mobile browser chrome crops that */
  overflow: hidden;
  text-align: center;

  /* NOT the 4.5rem every other section gets. This one is full-height and
     centres its own contents, so section padding is 144px of reserved space
     that does nothing but squeeze the invitation into the middle third of the
     artwork. Most guests open this on a phone; that squeeze was the whole
     problem. */
  padding-block: 2rem;
}

.hero-media {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: var(--paper);
}

/* The poster lives HERE, as a background-image, rather than on the video's
   `poster` attribute. That gives one code path instead of two: the still is
   present whether or not a video is ever attached, so the save-data path, the
   reduced-motion path and a mid-play `error` all land on the same picture with
   no flash and no empty frame. main.js only ever adds or removes the <video>. */
.hero-frame {
  position: absolute;
  inset: 0;
  background-image: url("../assets/hero-poster.jpg");
  background-size: cover;
  background-position: center;
}

.hero-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Cream scrim, two layers, both measured against the actual footage rather
   than eyeballed.

   The radial one buys the centre its contrast. 0.35 is the floor: the darkest
   tenth of the pixels under the text sit at #F5DFC9, which leaves --brass-dim
   at 4.45:1 unscrimmed — just under AA for small text. 0.35 takes it to 4.73:1
   and leaves the artwork visible. Denser than about 0.5 and you may as well
   not have a video.

   The linear one is not decoration. .hero-cue sits over the base garlands at
   #E2CCB4, which is darker than the centre, and a radial scrim THINS at the
   edges — the cue lands at 3.7:1 without this. It also dissolves the hero into
   the --paper section below instead of cutting to it.

   The THIRD layer, on top, is the bottom edge feather — added at phase 07.
   The 0.65 layer below was already described as dissolving the hero into the
   section beneath, but 0.65 is not a dissolve: it leaves the artwork 35%
   visible right up to the boundary, so the climbing roses were being cut by a
   dead-straight horizontal line where the hero met the countdown. This layer
   is short (6%) and reaches --paper outright, so the artwork actually ends in
   cream instead of being sliced.

   Painted as a gradient rather than added to .hero-frame's mask on purpose:
   the frame's mask is already a `to right` feather, a second edge means two
   mask layers plus mask-composite, and §16 documents why this codebase avoids
   that (Safari and the spec disagree on the keyword). The photo bands solve
   the same problem the same way.

   If the text ever fights, these get denser. The video is never darkened. */
.hero-scrim {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(to top,
      var(--paper), rgba(250, 245, 236, 0) 6%),
    linear-gradient(to top,
      rgba(250, 245, 236, 0.65), rgba(250, 245, 236, 0) 22%),
    radial-gradient(ellipse 60% 55% at 50% 45%,
      rgba(250, 245, 236, 0.35), rgba(250, 245, 236, 0.10) 78%);
}

.hero-body {
  position: relative;
  z-index: 1;
  align-self: center;
}

/* ----------------------------------------------------------------------------
   THE HERO BLOCK IS SIZED TWICE, AND THE TWO SIZINGS MEASURE DIFFERENT THINGS.

   PHONE (these rules): the artwork is full-bleed `cover`, so the thing the type
   has to fit is the VIEWPORT WIDTH — hence vw for type size. But the emptiness
   a phone shows is vertical, so every GAP is vh: the block opens up on a tall
   screen and closes on a short one without a breakpoint per device.

   DESKTOP (the min-width: 768px block below): the artwork is a portrait card
   whose width is a function of viewport HEIGHT, so there the type is vh too.

   Both are aimed at the same proportion — the long name at roughly 76-78% of
   whatever the visible artwork is — so the invitation looks like one design at
   both ends instead of two.
   -------------------------------------------------------------------------- */

.hero-eyebrow {
  /* Sized DOWN, against the trend of everything else in this block, and for a
     reason: at 32 tracked characters this is by some way the longest line here,
     and the artwork's clear middle is not centred — the painted column eats the
     left ~13% of a phone crop. At 0.75rem the line landed at 73% of a 383px
     screen, which put the W of "WE" on the column and broke the glyph. 2.9vw
     keeps its left edge clear of the column on a 375px screen and up.
     It still touches on a 320px phone (iPhone SE 1st gen); the honest fix there
     is the artwork, not the type, and recomposing the hero art is not this
     change's business. */
  font-size: clamp(0.6875rem, 2.9vw, 0.9375rem);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
}

/* The step between the eyebrow and the names.
   ITALIC, and that is the whole idea. Set upright it was just the names at a
   smaller size — the same face doing the same thing twice, which reads as a
   subtitle rather than as a line of its own. The italic is a different VOICE:
   Cormorant's italic is the elegant cut of the family, so the line earns its
   own presence without ever competing for the headline, because size and colour
   both still say the names win. It also rhymes with the italic ampersand
   directly below it, which makes the italic read as deliberate.
   Not uppercase: the eyebrow above is already tracked capitals, and two capital
   lines in a row collapse into one block.
   Tracking stays modest — italics tighten as they slope, and opening them up
   past ~0.05em breaks the join between letters that makes them elegant. */
.hero-occasion {
  margin-top: clamp(0.5rem, 1.8vh, 1.25rem);
  font-family: var(--font-display);
  font-style: italic;
  /* 400, not 300 — EB Garamond's weight axis starts at 400. */
  font-weight: 400;
  font-size: clamp(1.5rem, 6.5vw, 2.5rem);
  letter-spacing: 0.03em;
  line-height: 1.2;
  color: var(--ink);
}

.hero-names {
  margin: 0;
  font-family: var(--font-display);
  /* 400, not 300 — EB Garamond has no light. Compared against the old
     Cormorant Garamond 300 at this element's real 100.87px before the
     swap: modestly heavier, same character. */
  font-weight: 400;
  /* --ink, NOT --brass, and this is deliberate. --brass only ever cleared
     large-text AA by sitting on --paper (3.17:1). Measured over the hero
     artwork it is 2.67:1, and getting it back to 3:1 needs the scrim at
     ~0.70 — dense enough that there is no visible video left to justify.
     --ink measures 10.19:1 over the same pixels with no scrim at all.
     Gold stays in the hero on the eyebrow, the rule and the cue, where
     --brass-dim carries it. */
  color: var(--ink);

  /* 12vw put "Panayiotis" at 48% of a 390px phone — half the screen wide, on
     the biggest words on the page. 19vw lands it near 76%, which is the same
     proportion the desktop card gets. The vh term is the landscape guard: a
     phone on its side has the width for 19vw and nowhere near the height, so
     min() hands over to 10vh and the block still clears the scroll cue. */
  font-size: clamp(2.75rem, min(19vw, 10vh), 6rem);
  line-height: 1.1;
}

/* Block at EVERY width. The 768px rule that used to lay these out in a row is
   gone: the desktop artwork is a 450px-wide portrait card, and "Panayiotis &
   Teo" on one line overran it and read as one word. Stacked, each name sits
   inside the card. */
.hero-name,
.hero-amp {
  display: block;
}

/* The one place the script face appears in the hero. "&" never translates,
   which is the whole reason it is allowed to be Pinyon Script (see the
   --font-script note in §1).
   font-style is reset to normal: the face already slopes, and italic on top
   of it makes the browser synthesise a second shear.
   0.62em, not the old 0.5em — Pinyon Script's cap height is small against its
   em box, so matching the previous optical size needs the larger number. */
.hero-amp {
  font-family: var(--font-script);
  font-style: normal;
  font-size: 0.62em;
  line-height: 1;
  opacity: 0.8;
}

/* The rule and the date margin are the two biggest gaps in the block, so they
   are where the vertical fill is won or lost on a phone. Both in vh. */
/* Wider than the old bar. The flourish has a motif at its centre, so it needs
   enough width for the tails to read as tails rather than as stubs. */
.hero-body .ornament-rule {
  width: clamp(6.5rem, 26vw, 9rem);
  margin-block: clamp(1.25rem, 6vh, 3.5rem);
}

.hero-date {
  margin-top: clamp(1.25rem, 7vh, 4rem);
  font-size: clamp(0.875rem, 1.8vh, 1.0625rem);
  letter-spacing: 0.08em;
}

/* Its own line, and quieter than the date: on an invitation the day is the
   fact you have to remember and the city is context. */
.hero-place {
  margin-top: 0.5rem;
  font-size: clamp(0.8125rem, 1.65vh, 1rem);
  letter-spacing: 0.08em;
  color: var(--brass-dim);
}

.hero-cue {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  min-height: var(--tap);
  padding: 0.5rem 1rem;
  font-size: 0.6875rem;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
  text-decoration: none;
}

.hero-cue-line {
  width: 1px;
  height: 2.5rem;
  background: linear-gradient(to bottom, var(--brass-dim), transparent);
  transform-origin: top;
  animation: cue-drift 2.8s var(--ease) infinite;
}

@keyframes cue-drift {
  0%, 100% { transform: scaleY(0.45); opacity: 0.5; }
  50%      { transform: scaleY(1);    opacity: 1; }
}

@media (min-width: 768px) {
  /* ------------------------------------------------------------------
     THE TYPE IS SIZED AGAINST THE CARD, NOT AGAINST THE WINDOW.

     Above 768px the artwork stops being a full-bleed background and becomes
     a portrait card whose width is fixed by its own aspect ratio:
     768/1364 x the viewport height, i.e. about 0.56vh-per-unit. So the card
     is a function of HEIGHT, and type sized in vw ignored it completely —
     that is why the block sat small in the middle of a tall card with dead
     cream above and below it.

     Everything below is therefore sized in vh, so the whole block grows and
     shrinks WITH the card and keeps the same proportion inside it. The vw
     term stays in the min() only as a guard for wide-but-short windows, and
     the rem ceiling stops a 4K monitor from turning the names into a banner.

     The measure that matters: "Panayiotis" is 3.99em wide in Cormorant
     Garamond (measured on the live page with canvas measureText, not
     estimated — an eyeballed 3.2em put it at 96% of the card, touching the
     painted border). Card width is 0.563 x height, so 11vh lands the name at
     78% of the card at every height — inside the border with a margin, which
     is where a name belongs on an invitation.
     If the artwork is ever recropped to another aspect, or the display face
     changes, that 3.99 is the number to re-measure and these vh values are
     what has to move.
     ------------------------------------------------------------------ */

  /* 7rem of section padding was reserving 224px that the hero, being a
     full-height section with its own internal centring, has no use for. It
     was squeezing the block into the middle third of the card. */
  .hero {
    padding-block: 3rem;
  }

  .hero-eyebrow {
    font-size: clamp(0.75rem, 1.6vh, 0.9375rem);
  }

  .hero-occasion {
    margin-top: clamp(0.6rem, 1.5vh, 1.25rem);
    font-size: clamp(1.5rem, 4.5vh, 2.75rem);
  }

  /* 19vw, not 9vw. The vw term is only a guard against the type outgrowing a
     narrow WINDOW — the card is what normally limits it, via 11vh. At 9vw the
     guard was binding on tablet portrait (768x1024), where the card is 577px
     wide and there is room for a 113px name: it clamped to 69px and left the
     name at 48% of the card, the exact fault this whole change is fixing.
     19vw is the honest limit — the name is 3.99em, --pad-x is 3rem a side, so
     3.99 x 19vw fills about 90% of the padded width and no more. */
  .hero-names {
    font-size: clamp(3rem, min(11vh, 19vw), 7.5rem);
  }

  .hero-date {
    margin-top: clamp(1.5rem, 5vh, 3.5rem);
    font-size: clamp(0.875rem, 1.9vh, 1.125rem);
  }

  .hero-place {
    margin-top: 0.6rem;
    font-size: clamp(0.8125rem, 1.7vh, 1rem);
  }

  /* The rule scales with the block it separates, or it reads as a dash.

     THE GAPS DO AS MUCH WORK HERE AS THE TYPE SIZE. The name is capped at 78%
     of the card by the card's own width — past that it touches the painted
     border — so the block cannot be made to fill a tall card by growing the
     letters alone. What fills it is the rhythm between the lines, which is why
     these margins are in vh: on a tall window the block opens up, on a 620px
     one every gap collapses to its rem floor and the whole thing still clears
     the scroll cue. */
  .hero-body .ornament-rule {
    width: clamp(6.5rem, 13vh, 9rem);
    margin-block: clamp(1.5rem, 4.5vh, 3rem);
  }

  /* The artwork is 768x1364 portrait, so on a landscape viewport `cover`
     would throw away about 62% of its height — exactly the column capitals
     and the base garlands that make the composition. Instead it becomes a
     full-height portrait panel, centred on --paper, feathered into the cream
     at both sides so it reads as artwork on paper rather than a pasted
     rectangle. Keeping it at its native height also keeps a 768px-wide source
     off a 1440px screen, where it would visibly upscale. */
  .hero-frame {
    inset: 0 auto;
    left: 50%;
    transform: translateX(-50%);
    width: auto;
    height: 100%;
    aspect-ratio: 768 / 1364;
    -webkit-mask-image:
      linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
            mask-image:
      linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
  }
}

/* --------------------------------------------------------------------------
   HERO — PHONE HELD SIDEWAYS

   A landscape phone is ~390px tall and, being over 768px wide, takes the
   desktop rules. Five stacked lines plus a scroll cue do not fit in 390px at
   any size that is still worth looking at: every vh value collapses to its rem
   floor and the block still computes taller than the screen.

   So it stops pretending. `min-height: 0` drops the full-screen ambition — the
   hero becomes as tall as its contents, the guest sees the names and the date
   without scrolling, and the countdown follows immediately underneath, which
   is a better landscape experience than a screen-height block of cream with
   the cue pushed under the fold.
   -------------------------------------------------------------------------- */
@media (orientation: landscape) and (max-height: 500px) {
  .hero {
    min-height: 0;
    padding-block: 2.5rem;
  }

  .hero-names {
    font-size: clamp(2rem, 11vh, 3rem);
  }

  .hero-occasion {
    font-size: clamp(1.125rem, 5vh, 1.5rem);
  }

  .hero-body .ornament-rule {
    margin-block: clamp(0.75rem, 3vh, 1.25rem);
  }

  .hero-date {
    margin-top: clamp(0.75rem, 4vh, 1.5rem);
  }
}

/* --------------------------------------------------------------------------
   8. COUNTDOWN

   FOUR COLUMNS AT EVERY WIDTH, phone included. It was 2x2 below 768px, which
   split one continuous quantity into two rows and read as two separate facts;
   a countdown is one line or it is nothing. Still deliberately NOT auto-fit —
   with exactly four units, auto-fit produces a 3+1 orphan across the whole
   small-tablet range.

   The column gap is 0.5rem rather than 1rem for the same reason: at 320px the
   four columns are only 58px each, and the gap is the cheapest place to find
   the width. Desktop takes its 2rem back below.

   What has to survive is the LABEL, not the count. "Δευτερόλεπτα" is nearly
   twice "Seconds", so it wraps — that is expected, and the units are
   top-aligned so a wrapped label never shifts its neighbours. Measured in
   Chrome at 320/360/390/430px in both languages: one row, no label or value
   overflows its column, no horizontal page scroll. The Greek breaks as
   ΔΕΥΤΕΡΟ-ΛΕΠΤΑ with a real hyphen, which works only because `hyphens: auto`
   has a `lang` to hyphenate against — js/main.js sets documentElement.lang on
   switch. If that ever stops happening the word breaks mid-stroke with no
   hyphen, and this is where it will show first.
   PHASE 03 measures the real Greek and sets its own tracking.
   -------------------------------------------------------------------------- */

.count-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 2rem 0.5rem;
  margin-top: 2.5rem;
  text-align: center;
}

.count-unit {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  min-width: 0;
}

.count-value {
  font-family: var(--font-display);
  /* Explicit. body sets 300, and EB Garamond has no 300 — leaving it
     inherited means relying on the browser clamping up to 400 silently. */
  font-weight: 400;
  font-size: clamp(2.5rem, 9vw, 4rem);
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.count-unit-label {
  font-size: 0.6875rem;
  line-height: 1.3;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
  overflow-wrap: break-word;
  hyphens: auto;
}

.count-today {
  margin-top: 2.5rem;
  text-align: center;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: clamp(2rem, 8vw, 3.5rem);
  color: var(--brass);
}

@media (min-width: 768px) {
  /* The columns are already four; this is only the gap the phone could not
     afford. */
  .count-grid {
    gap: 2rem;
  }
}

/* --------------------------------------------------------------------------
   9. THE DAY — timeline

   ZIG-ZAG AT EVERY WIDTH. Entries alternate right/left of a centred spine on a
   360px phone exactly as they do on a desktop. There is deliberately NO
   single-column fallback: the alternation is the shape of the section, and a
   layout that only appears above 768px is a layout most guests never see.

   --spine-x is the one number that positions the spine and every dot, and it
   is now 50% everywhere. --rail is the clear gutter the spine runs down.

   THE 360px CONSTRAINT sets the base type scale. At 360px the container is
   360 - 2×1.5rem = 312px, so each half-column is (312 - --rail) / 2 ≈ 148px.
   Every size in the base block is chosen so that the two worst strings —
   "Saint Nicholas Church, Stavonos 18, Larnaca 6035" and "Congratulations" —
   wrap inside that column without overflowing and without crossing the spine.
   ⚠️ RAISING ANY BASE FONT-SIZE OR LETTER-SPACING HERE MEANS RE-CHECKING BOTH
   STRINGS AT 360px. The couple should confirm this on a real ~360px handset.

   .day-spine is a SINGLE absolutely-positioned element running the whole
   timeline. It was kept that way for a scroll-linked flower that has since
   been cancelled, but the shape still earns its keep: the sprigs in
   .day-ornament attach to it as though it were a stem, which only reads if
   the line is continuous through them.
   -------------------------------------------------------------------------- */

.day-timeline {
  --spine-x: 50%;        /* centred at EVERY width — see the note above */
  --dot-y:   0.5rem;     /* centre of the dot, from the top of an entry */
  --rail:    1rem;       /* clear gutter the spine runs down */

  /* Band the cyclamen sprigs in .day-ornament are drawn into. */
  --ornament-w: 2.5rem;
  --ornament-h: 3.25rem;

  /* Height of the per-entry event icon. It is the ONLY dimension given —
     width comes from the artwork via background-size: contain, so the six
     drawings sit on a shared baseline height instead of a shared width and
     the tall dress does not tower over the wide champagne glasses. */
  --icon-h: 4.5rem;

  /* How far the icons drift. In px, not em: this is a fixed optical nudge,
     and tying it to the type scale would let it grow into a bob at exactly
     the widths where the drawings are already largest. */
  --float-lift: 3px;

  position: relative;
  margin-top: 3rem;
}

.day-spine {
  position: absolute;
  top: var(--dot-y);
  bottom: 0;
  left: var(--spine-x);
  width: 1px;
  transform: translateX(-50%);
  background: var(--rule-strong);
}

/* Two equal halves with the spine down the middle of the gutter. BOTH the
   time and the detail sit in ONE column, so an entry never straddles the
   spine — that is what keeps "VIEW ON MAP ↗" on its own side. */
.day-entry {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: var(--rail);
  align-items: start;
}

/* THE ALTERNATION.

   :nth-of-type, NOT :nth-child. .day-spine and the five .day-ornament hooks
   are siblings inside .day-timeline, so :nth-child would count them and the
   entries would stop alternating. Every .day-entry is an <article> and it is
   the only <article> in .day-timeline, which is what makes :nth-of-type
   count entries and nothing else.

   Odd entries take the right column, even entries the left. Source order is
   chronological and must stay that way — never reorder the markup to move an
   entry across the spine. */
.day-entry > .day-time,
.day-entry > .day-detail {
  grid-column: 2;
  text-align: left;
}

.day-entry:nth-of-type(even) > .day-time,
.day-entry:nth-of-type(even) > .day-detail {
  grid-column: 1;
  text-align: right;
}

/* Grid items are min-content wide by default, so a long unbroken address
   would push its column past its half and across the spine. This is the rule
   that holds the 148px column at 360px. */
.day-time,
.day-detail {
  min-width: 0;
  overflow-wrap: break-word;
}

/* THE EVENT ICONS.

   One drawing per entry, in the half-column the text does not use, so it
   faces its own event across the spine. It is EXPLICITLY placed — row 1,
   spanning both rows — rather than auto-flowed. That matters twice: the
   auto-placed .day-time and .day-detail keep the rows they already had, and
   spanning both is what lets the icon centre against the whole entry instead
   of stacking above the time. All six entries carry one.

   ⚠️ WHY background-image AND NOT INLINE <svg>. Every file in assets/icons/
   carries its own <style> block, and Illustrator named the classes .st0,
   .st1, .st2 … in all five. Inline two of them in one document and those
   blocks are GLOBAL: the last one parsed repaints the others, and the suit
   turns into a solid gold silhouette. As a background-image each file is its
   own document and the collision cannot happen. Do not "optimise" these into
   inline SVGs without renaming every class first.

   ⚠️ assets/icons/*.svg IS NOT assets/*.svg. The icons are the source art
   recoloured to one --brass-dim (the originals are olive, grey and
   white-filled) and cropped from the A4 Illustrator page down to the drawing.
   Point these rules at the originals and you get mismatched colours floating
   at half size in a field of whitespace. See assets/README.md. */
.day-icon {
  grid-row: 1 / span 2;
  align-self: center;
  width: 100%;
  height: var(--icon-h);
  background: center / contain no-repeat;

  /* THE FLOAT — a few pixels of vertical drift, meant to be almost
     subliminal. The drawings breathe; they do not bob. Six of them moving at
     once is a lot of movement beside text a guest is trying to read, which is
     why the amplitude is single-digit pixels and the cycle is a slow 7s.

     ⚠️ THIS SHORTHAND MUST STAY ABOVE THE .day-icon--* RULES. `animation` is
     a shorthand, so it resets animation-delay to 0s — and those rules carry
     the per-icon delays at the same specificity. Below them it silently wins
     and all six drift in lockstep, which is exactly the effect the delays
     exist to prevent. It looks fine in a screenshot and wrong in motion.

     ease-in-out, not --ease. --ease is an out-curve, right for something that
     arrives once and settles (the reveals, the hero cue). A drift that turns
     around at both ends wants to slow into each turn, and an asymmetric curve
     makes the two halves of the cycle look uneven. */
  animation: icon-float 7s ease-in-out infinite;
}

/* ⚠️ 0% AND 100% ARE BOTH AT REST, and that is what makes §14 safe. The
   reduced-motion block there collapses every animation to 0.01ms and clamps
   it to a single iteration, so whatever the LAST keyframe holds becomes a
   permanent offset. Written the obvious way — rest at 0%, lifted at 100% —
   all six icons would sit a few pixels high forever on exactly the machines
   that asked for no motion. */
@keyframes icon-float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(calc(var(--float-lift) * -1)); }
}

/* The icon takes whichever column the text did not — the mirror of the
   .day-time / .day-detail alternation above, and it has to stay the mirror.
   Flipping one without the other puts a drawing on top of an address.

   background-position pulls the drawing to the INNER edge of its column, so
   it hugs the spine the way the text opposite it does. Centring it instead
   is what it looks like if you leave this off, and above 768px the column is
   ~430px against a ~90px drawing: the icons drift out into their own empty
   half and the timeline stops reading as one centred band. The rail keeps
   them off the spine — they never touch it. */
.day-entry > .day-icon {
  grid-column: 1;
  background-position: right center;
}

.day-entry:nth-of-type(even) > .day-icon {
  grid-column: 2;
  background-position: left center;
}

/* Each icon carries its own art AND its own phase, in one rule. The delays
   are NEGATIVE on purpose: a negative delay starts the animation already
   part-way through its cycle, so the six drawings are at six different points
   of the drift from the first painted frame. Positive delays would park them
   all at rest and then release them together, which is the one thing this
   effect must not look like. The values are not multiples of each other. */
.day-icon--groom      { background-image: url("../assets/icons/groom.svg");           animation-delay: -0.4s; }
.day-icon--bride      { background-image: url("../assets/icons/bride.svg");           animation-delay: -2.9s; }
.day-icon--church     { background-image: url("../assets/icons/church.svg");          animation-delay: -1.3s; }
.day-icon--cocktails  { background-image: url("../assets/icons/cocktails.svg");       animation-delay: -4.6s; }
.day-icon--congrats   { background-image: url("../assets/icons/congratulations.svg"); animation-delay: -3.4s; }
.day-icon--party      { background-image: url("../assets/icons/party.svg");           animation-delay: -5.8s; }

/* THE BOTANICALS. The hook these fill was reserved empty from phase 04 and is
   filled here at phase 07; the band, the centring and the spacing contract
   below are unchanged, so nothing reflowed when the art arrived.

   THE PLANT IS CYCLAMEN CYPRIUM, and that is not arbitrary. It is endemic to
   Cyprus and it flowers September to November, so it is genuinely in bloom on
   31 October. It is also not the rose — the hero artwork is already full of
   roses, so a second rose here would read as wallpaper rather than as a
   second voice.

   THE SPINE IS THE STEM. .day-spine runs the full height of the timeline and
   passes straight through these slots, so each drawing attaches to it rather
   than carrying a stem of its own: a stalk arcs off the spine, the bloom nods
   at its tip, a leaf hangs the other way. That is why every path starts at
   x=28 — the horizontal centre of the 56-wide viewBox, which margin-inline:
   auto puts exactly on the spine.

   THE BLOOM IS BUILT FROM ROTATED ELLIPSES, NOT STROKES. Cyclamen petals are
   reflexed — swept up and back off a downward-hanging nose — and drawn as
   open strokes at this size that silhouette reads as a spider's legs. Drawn,
   inspected at 4x, rejected. Closed ellipses give the petals area, which is
   what makes them read as petals.

   THE LEAF IS THE SWASH LEAF. Same pointed lens with a midrib that the header
   swashes and the hero flourish end in, so the whole ornament system is one
   vocabulary. A botanically-correct cordate cyclamen leaf was tried first and
   abandoned: cordate means heart-shaped, and upright at this scale it stops
   reading as a leaf and starts reading as ♥.

   THREE DRAWINGS, NOT FIVE. A (bloom right), B (bloom left), and C — leaves
   and a bud, no bloom — as a quieter beat so the run does not pulse evenly.
   Sequence is A B C A B, assigned by modifier class in the markup rather than
   :nth-of-type, so reordering the timeline cannot silently pair two alike. */
.day-ornament {
  display: block;
  width: var(--ornament-w);
  height: var(--ornament-h);
  margin-inline: auto;
  background: var(--sprig) center / contain no-repeat;
}

.day-ornament--a { --sprig: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='72' viewBox='0 0 56 72' fill='none' stroke='%237E6224' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M28 46C31.5 43 35 39.5 36.5 34' stroke-width='1'/%3E%3Cpath d='M36.5 44.5C33.1 43 33.1 37 36.5 35c3.4 2 3.4 8 0 9.5z' stroke-width='1'/%3E%3Cellipse cx='0' cy='-8.5' rx='2.9' ry='8' transform='translate(36.5 35) rotate(-26)' stroke-width='1'/%3E%3Cellipse cx='0' cy='-9' rx='2.9' ry='8.5' transform='translate(36.5 35) rotate(8)' stroke-width='1'/%3E%3Cellipse cx='0' cy='-8.5' rx='2.9' ry='8' transform='translate(36.5 35) rotate(42)' stroke-width='1'/%3E%3Cpath d='M28 55c-2.2 1.2-3.8 2.2-5.2 3.4' stroke-width='1'/%3E%3Cg transform='translate(22.8 58.4) rotate(168) scale(.92)'%3E%3Cpath d='M0 0c4-3.5 10-3.9 14.4 0-4.4 3.9-10.4 3.5-14.4 0z' stroke-width='1'/%3E%3Cpath d='M3 0h8.4' stroke-width='.7' opacity='.55'/%3E%3C/g%3E%3C/svg%3E"); }

.day-ornament--b { --sprig: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='72' viewBox='0 0 56 72' fill='none' stroke='%237E6224' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M28 46C24.5 43 21 39.5 19.5 34' stroke-width='1'/%3E%3Cpath d='M19.5 44.5C16.1 43 16.1 37 19.5 35c3.4 2 3.4 8 0 9.5z' stroke-width='1'/%3E%3Cellipse cx='0' cy='-8.5' rx='2.9' ry='8' transform='translate(19.5 35) rotate(-42)' stroke-width='1'/%3E%3Cellipse cx='0' cy='-9' rx='2.9' ry='8.5' transform='translate(19.5 35) rotate(-8)' stroke-width='1'/%3E%3Cellipse cx='0' cy='-8.5' rx='2.9' ry='8' transform='translate(19.5 35) rotate(26)' stroke-width='1'/%3E%3Cpath d='M28 55c2.2 1.2 3.8 2.2 5.2 3.4' stroke-width='1'/%3E%3Cg transform='translate(33.2 58.4) rotate(12) scale(.92)'%3E%3Cpath d='M0 0c4-3.5 10-3.9 14.4 0-4.4 3.9-10.4 3.5-14.4 0z' stroke-width='1'/%3E%3Cpath d='M3 0h8.4' stroke-width='.7' opacity='.55'/%3E%3C/g%3E%3C/svg%3E"); }

.day-ornament--c { --sprig: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='72' viewBox='0 0 56 72' fill='none' stroke='%237E6224' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M28 34c-1.6 2.6-2.4 5-2.6 7.8' stroke-width='1'/%3E%3Cg transform='translate(25.4 41.8) rotate(150) scale(.8)'%3E%3Cpath d='M0 0c4-3.5 10-3.9 14.4 0-4.4 3.9-10.4 3.5-14.4 0z' stroke-width='1'/%3E%3Cpath d='M3 0h8.4' stroke-width='.7' opacity='.55'/%3E%3C/g%3E%3Cpath d='M28 44c1.6 2.6 2.4 5 2.6 7.8' stroke-width='1'/%3E%3Cg transform='translate(30.6 51.8) rotate(30) scale(.8)'%3E%3Cpath d='M0 0c4-3.5 10-3.9 14.4 0-4.4 3.9-10.4 3.5-14.4 0z' stroke-width='1'/%3E%3Cpath d='M3 0h8.4' stroke-width='.7' opacity='.55'/%3E%3C/g%3E%3Ccircle cx='28' cy='38.6' r='1.7' stroke-width='.9'/%3E%3C/svg%3E"); }

.day-entry + .day-ornament,
.day-ornament + .day-entry {
  margin-top: 1.5rem;
}

/* --------------------------------------------------------------------------
   DRIFTING PETALS

   All that remains of phase 08. The scroll-linked flower it was written around
   was cancelled, which is why there is no GSAP anywhere in this project and
   js/vendor/ does not exist: these are CSS keyframes and nothing else.

   FIVE, IN ONE SECTION. Restraint is the whole design here. Continuous
   decorative motion is a known irritant and a documented accessibility
   problem, so this is deliberately the only ambient animation on the page,
   it is slow (15–23s), and it is confined to the timeline — the tallest
   section and the only one with standing empty cream.

   THEY PASS BEHIND THE TEXT. .petals is z-index 0 and #day's .section-inner
   is z-index 1, so a petal physically cannot land on top of a word. That is
   a structural guarantee rather than a placement one, which matters because
   placement that works at 1440 does not survive 390.

   TRANSFORM AND OPACITY ONLY — never top/left, which would force layout on
   every frame of a 23-second loop. No will-change: five small composited
   layers do not need the hint, and paying for it on every petal is exactly
   the misuse that makes will-change counterproductive.

   NEGATIVE DELAYS start each petal mid-flight, so the section is already in
   motion when a guest reaches it instead of beginning with an empty sky. */
.petals {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}

/* overflow:hidden lives on .petals, NOT on the section. #day has no torn top
   edge today (it follows a photo band, which §16 cancels), but the edge is a
   pseudo-element positioned ABOVE the section's box — so clipping the section
   would silently amputate it if the page were ever reordered. */
#day {
  position: relative;
}

#day > .section-inner {
  position: relative;
  z-index: 1;
}

.petal {
  position: absolute;
  top: 0;
  width: var(--size);
  height: calc(var(--size) * 1.17);
  opacity: 0;
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='14' viewBox='0 0 12 14'%3E%3Cpath d='M6 .8C10.6 4.4 11.8 9 6.4 13.2 1 9.6 1.4 4.6 6 .8z' fill='%237E6224'/%3E%3C/svg%3E")
    center / contain no-repeat;
  animation: petal-drift var(--dur-fall) linear var(--delay) infinite;
}

@keyframes petal-drift {
  0%   { opacity: 0;    transform: translate3d(0, -8vh, 0) rotate(0deg); }
  9%   { opacity: 0.34; }
  86%  { opacity: 0.34; }
  100% { opacity: 0;    transform: translate3d(var(--drift), 128vh, 0) rotate(var(--spin)); }
}

/* Every value differs so no two petals ever pair up and read as a pattern. */
.petal--1 { left:  8%; --size: 14px; --dur-fall: 17s; --delay:  -2s; --drift:  26px; --spin:  190deg; }
.petal--2 { left: 24%; --size: 10px; --dur-fall: 21s; --delay:  -9s; --drift: -34px; --spin: -160deg; }
.petal--3 { left: 52%; --size: 12px; --dur-fall: 15s; --delay: -14s; --drift:  40px; --spin:  220deg; }
.petal--4 { left: 74%; --size:  9px; --dur-fall: 19s; --delay:  -5s; --drift: -22px; --spin: -200deg; }
.petal--5 { left: 90%; --size: 13px; --dur-fall: 23s; --delay: -18s; --drift:  30px; --spin:  170deg; }

/* Fallback rhythm for any pair with no ornament between them. */
.day-entry + .day-entry {
  margin-top: 3rem;
}

.day-dot {
  position: absolute;
  top: var(--dot-y);
  left: var(--spine-x);
  width: 9px;
  height: 9px;
  margin: -4.5px 0 0 -4.5px;
  border-radius: 50%;
  /* Opaque fill, so the spine does not show through the middle of the dot. */
  background: var(--paper);
  box-shadow: inset 0 0 0 1px var(--brass-dim);
}

/* The three sizes below are the 360px floor. See the §9 header. */
.day-time {
  font-size: 0.6875rem;
  line-height: 1.4;
  letter-spacing: 0.06em;
  color: var(--brass-dim);
  font-variant-numeric: tabular-nums;
}

.day-title {
  margin-top: 0.2rem;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 1.0625rem;
  line-height: 1.2;
}

.day-location {
  margin-top: 0.2rem;
  font-size: 0.75rem;
  line-height: 1.45;
  color: var(--ink-soft);
}

.day-map,
.card-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: var(--tap);
  margin-top: 0.25rem;
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--brass-dim);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-color var(--dur-ui) ease;
}

/* Underline on hover AND on focus — the link is distinguished from body text
   by colour, size and tracking at rest, so this is emphasis, not the only
   affordance. */
.day-map:hover,
.day-map:focus-visible,
.card-link:hover,
.card-link:focus-visible {
  border-bottom-color: var(--brass-dim);
}

/* The map link tightens with the rest of the entry at the 360px floor. The
   tap target does NOT shrink with it — min-height stays --tap above. */
.day-map {
  font-size: 0.625rem;
  letter-spacing: 0.06em;
}

@media (min-width: 480px) {
  .day-timeline {
    --rail: 1.75rem;
    --ornament-w: 3rem;
    --ornament-h: 3.75rem;
    --icon-h: 5.5rem;
  }

  .day-time     { font-size: 0.75rem;   letter-spacing: 0.08em; }
  .day-title    { font-size: 1.25rem; }
  .day-location { font-size: 0.8125rem; }
  .day-map      { font-size: 0.6875rem; letter-spacing: 0.08em; }

  .day-entry + .day-ornament,
  .day-ornament + .day-entry {
    margin-top: 1.75rem;
  }
}

@media (min-width: 768px) {
  .day-timeline {
    --dot-y: 0.55rem;
    --rail:  4rem;
    --ornament-w: 3.5rem;
    --ornament-h: 4.5rem;
    --icon-h: 7.5rem;
    --float-lift: 5px;
  }

  .day-time     { font-size: 0.8125rem; letter-spacing: 0.1em; }
  .day-location { font-size: 0.9375rem; margin-top: 0.25rem; }
  .day-map      { font-size: 0.75rem;   letter-spacing: 0.1em; }

  .day-title {
    margin-top: 0.25rem;
    font-size: 1.5rem;
    line-height: 1.25;
  }

  .day-entry + .day-ornament,
  .day-ornament + .day-entry {
    margin-top: 2.25rem;
  }

  .day-entry + .day-entry {
    margin-top: 3.5rem;
  }
}

/* --------------------------------------------------------------------------
   10. GUEST GUIDE

   Three cards per group, and they stay three. auto-fit would break the triad
   into 2+1 on a tablet, which reads as two groups rather than one.
   -------------------------------------------------------------------------- */

.guide-group {
  margin-top: 3rem;
}

.guide-heading {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 1.375rem;
  text-align: center;
}

.guide-cards {
  display: grid;
  gap: 1.25rem;
  margin-top: 1.5rem;
}

/* THE CARD BORDER. A bare 1px rectangle until phase 07.

   The card's ground and the section's ground are the SAME cream, so the
   border is not decoration here — it is the only thing that makes a card a
   card. That rules out replacing it with corner marks alone: six unbounded
   blocks of text on one cream field stop reading as six things.

   So the hairline stays and takes corner brackets, which is the treatment a
   printed invitation or a bookplate uses for exactly this reason — it turns a
   drawn box into a deliberate frame without adding weight. Brackets are
   --brass-dim at full strength against the border's --rule, so the corners
   read as the drawn element and the sides recede.

   Four separate paths rather than one rotated mark: the bracket is an L and
   has no symmetry to exploit, so each corner is its own short path. They are
   inset 5px, which places them just inside the hairline rather than on it —
   sitting on it reads as a thickened corner, not as an ornament. */
.guide-card {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding: 1.5rem;
  border: 1px solid var(--rule);
  background-color: var(--paper);
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%237E6224' stroke-linecap='round'%3E%3Cpath d='M1 11V4.2C1 2.4 2.4 1 4.2 1H11'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%237E6224' stroke-linecap='round'%3E%3Cpath d='M11 11V4.2C11 2.4 9.6 1 7.8 1H1'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%237E6224' stroke-linecap='round'%3E%3Cpath d='M1 1v6.8C1 9.6 2.4 11 4.2 11H11'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%237E6224' stroke-linecap='round'%3E%3Cpath d='M11 1v6.8C11 9.6 9.6 11 7.8 11H1'/%3E%3C/svg%3E");
  background-position:
    left 5px top 5px,
    right 5px top 5px,
    left 5px bottom 5px,
    right 5px bottom 5px;
  background-size: 12px 12px;
  background-repeat: no-repeat;
}

/* A stay card carries TWO links (Book and Map) and they sit on one row.
   margin-top:auto pushes the row to the bottom of the card, so the links line
   up across a group whose descriptions run to different lengths. Cards with
   one link, or none, use the same block and simply hold less. */
.card-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 1.5rem;
  margin-top: auto;
}

.card-title {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.125rem;
  line-height: 1.3;
}

.card-desc {
  font-size: 0.9375rem;
  color: var(--ink-soft);
}

/* Pushes every link to the bottom, so a row of cards with different amounts
   of text still lines its links up. */
.card-link {
  margin-top: auto;
  align-self: flex-start;
}

.guide-notice {
  max-width: 46ch;
  margin: 3rem auto 0;
  padding-top: 2rem;
  border-top: 1px solid var(--rule);
  text-align: center;
  font-size: 0.875rem;
  color: var(--ink-soft);
}

@media (min-width: 768px) {
  .guide-cards {
    grid-template-columns: repeat(3, 1fr);
    gap: 1.5rem;
  }
}

/* --------------------------------------------------------------------------
   11. RSVP FORM — and, at the end of this section, the popup that holds it

   Floating labels over underline inputs. The label is a real <label for>, not
   a placeholder — the placeholder is a single space, which exists only so
   :placeholder-shown can tell an empty field from a filled one. A
   placeholder-as-label disappears the moment a guest starts typing and takes
   the question with it.

   Everything down to .success-text is the FORM, and it is the same form on
   both paths — in the page with scripting off, in an overlay with scripting
   on. Everything after it is the overlay. Keep them apart: the popup is a
   container, and a control that only looks right inside it is a control that
   is wrong.
   -------------------------------------------------------------------------- */

.rsvp-form {
  display: flex;
  flex-direction: column;
  gap: 2rem;
  margin-top: 2.5rem;
}

.field {
  position: relative;
}

.field input,
.field textarea {
  width: 100%;
  min-height: var(--tap);
  padding: 1.5rem 0 0.5rem;
  font: inherit;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-bottom: 1px solid var(--rule-strong);
  border-radius: 0;   /* iOS rounds inputs by default */
  -webkit-appearance: none;
  appearance: none;
  transition: box-shadow var(--dur-ui) ease, border-color var(--dur-ui) ease;
}

.field textarea {
  resize: vertical;
}

/* .phone-number is the second column of the phone row and is a `.field` in
   everything but name — it carries an input and its floating label, but it
   cannot BE a .field because the row's two columns share one error message
   below them. The two transform rules underneath reach it already: they are
   written `.field input:focus + label`, and the label is still the input's
   sibling. Only this one, which is a child combinator, has to be told. */
.field > label,
.phone-number > label {
  position: absolute;
  top: 1.5rem;
  left: 0;
  color: var(--ink-soft);
  pointer-events: none;
  transform-origin: left top;
  transition: transform var(--dur-ui) ease, color var(--dur-ui) ease;
}

.field input:focus + label,
.field textarea:focus + label,
.field input:not(:placeholder-shown) + label,
.field textarea:not(:placeholder-shown) + label {
  transform: translateY(-1.4rem) scale(0.75);
  color: var(--brass-dim);
}

/* Thickened with an inset shadow rather than a wider border, so focusing a
   field does not shift the line below it by a pixel. The global
   :focus-visible outline still applies — this is in addition to it. */
.field input:focus,
.field textarea:focus {
  border-bottom-color: var(--brass-dim);
  box-shadow: inset 0 -1px 0 var(--brass-dim);
}

.field input[aria-invalid="true"],
.field textarea[aria-invalid="true"] {
  border-bottom-color: var(--error);
}

.field-error,
.form-error {
  margin-top: 0.5rem;
  font-size: 0.8125rem;
  color: var(--error);
}

.form-error {
  text-align: center;
}

/* ---- attending (radio) ---- */

.field--radio > legend {
  font-size: 0.75rem;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
}

.radio-row {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin-top: 0.75rem;
}

.radio-option {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  min-height: var(--tap);
  cursor: pointer;
}

/* The native input is kept in the accessibility tree and in the tab order —
   it is hidden visually only, and .radio-mark carries its focus ring. */
.radio-option input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.radio-mark {
  position: relative;
  flex: none;
  width: 1.125rem;
  height: 1.125rem;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--brass-dim);
}

.radio-mark::after {
  content: "";
  position: absolute;
  inset: 4px;
  border-radius: 50%;
  background: var(--brass-dim);
  transform: scale(0);
  transition: transform var(--dur-ui) var(--ease);
}

.radio-option input:checked + .radio-mark::after {
  transform: scale(1);
}

.radio-option input:focus-visible + .radio-mark {
  outline: 2px solid var(--brass-dim);
  outline-offset: 3px;
}

.field--radio[aria-invalid="true"] .radio-mark {
  box-shadow: inset 0 0 0 1px var(--error);
}

/* ---- submit ---- */

.btn {
  align-self: center;
  min-height: 3rem;
  padding: 0.875rem 2.5rem;
  border: 1px solid var(--brass-dim);
  color: var(--brass-dim);
  background: transparent;
  font-size: 0.75rem;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  transition: background-color var(--dur-ui) ease, color var(--dur-ui) ease;
}

.btn:hover {
  background-color: var(--brass-dim);
  color: var(--paper);
}

.btn[disabled] {
  opacity: 0.5;
  cursor: default;
}

.btn[disabled]:hover {
  background-color: transparent;
  color: var(--brass-dim);
}

/* ---- success ---- */

.rsvp-success {
  margin-top: 2.5rem;
  text-align: center;
}

/* --brass at 2.6rem clears the 3:1 large-text threshold on --paper-alt, and
   this monogram sits on solid ground with nothing behind it. It is the one
   celebratory moment in the form, so gold earns its place here. */
.success-mark {
  font-family: var(--font-script);
  font-size: 2.6rem;
  line-height: 1;
  color: var(--brass);
}

.success-title {
  margin-top: 0.75rem;
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 1.75rem;
}

.success-text {
  max-width: 34ch;
  margin: 0.75rem auto 0;
  color: var(--ink-soft);
}

/* ==========================================================================
   THE POPUP — RSVP 01

   THE FORM IS NOT MOVED INTO A DIALOG. IT IS PROMOTED INTO ONE.

   The markup stays inline in <main>, exactly where it has always been, and
   these rules turn it into a fixed overlay only when the `.js` class set in
   <head> says scripting is available. Same trick as .entrance (§15), same
   reason, and it is the whole no-JS story:

     scripting off   the button is display:none, .rsvp-dialog is a plain block,
                     and the guest fills in the form in the page and posts it
                     natively to php/rsvp.php;
     scripting on    the button appears, the form goes away, and pressing the
                     button paints the same markup as an overlay.

   One set of markup, one set of field names, one server parser. Nothing is
   duplicated, so nothing can drift.

   NOTHING IN HERE INTRODUCES A COLOUR, A FACE, A SHADOW OR A RADIUS THAT IS
   NOT ALREADY ON THIS PAGE. The scrim is --ink at 0.55, derived the same way
   --ink-soft and --rule are. The panel is --paper with a --rule hairline. The
   caret and the controls inside are §11's, unchanged: if a control in here
   looks different from the same control elsewhere on the site, that is the
   bug. The dialog is a container, not a new design language.
   ========================================================================== */

.rsvp-open   { display: none; }
.rsvp-dialog { display: block; }

/* ---- the pending state ----

   `.rsvp-pending` on #rsvp is what ships while php/rsvp.php does not exist —
   see the long note at the section in index.html. These four rules are the
   whole of it, and they sit here at the top of §11 rather than at the bottom
   so that whoever deletes the class can see everything it was doing.

   !important is load-bearing, not laziness: `.js .rsvp-open` below is a
   two-class selector that would otherwise win, and this rule has to beat
   every route that shows the form regardless of what else is on the element.

   The dialog is display:none rather than `hidden` because §11 promotes it to
   a fixed overlay under `.js`, and an overlay that is merely transparent is
   still a full-screen surface sitting over the page. */
.rsvp-pending .rsvp-open,
.rsvp-pending .rsvp-dialog,
.rsvp-pending .rsvp-replied { display: none !important; }

/* The mirror image: the notice exists in the markup at all times and is only
   ever visible in the pending state. */
.rsvp-soon { display: none; }

.rsvp-pending .rsvp-soon {
  display: block;
  margin-top: 2.375rem;   /* matches .js .rsvp-open's, so nothing jumps */
  text-align: center;
  font-size: 0.9375rem;
  color: var(--ink-soft);
}

/* ---- the seal that opens it ----

   THE SECOND WAX SEAL ON THIS SITE. The entrance (§15) is tapped to open the
   invitation; this one is tapped to answer it. Reusing the object rather than
   inventing a button is what ties the two ends of the visit together, and it
   is why this is artwork with a hint under it instead of the bordered .btn it
   replaced — .btn is still what the form's own submit uses, inside the dialog.

   ⚠️ display:none here does NOT stop the seal downloading. That was measured:
   Chrome fetches a loading="lazy" image inside a display:none element anyway.
   The 30 KB is kept off the no-JS path by shipping the <img> with data-src and
   letting js/main.js §8 promote it — see the note there. Do not "simplify"
   that back into a plain src on the strength of this rule. */
.js .rsvp-open {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
  /* The padding is for the FOCUS RING. §2's outline sits 3px off the box, and
     with the button shrink-wrapped to its contents that put a gold rectangle
     hard against the caption's letterforms. This gives it somewhere to land.
     The margin is reduced by the same amount so nothing moves. */
  padding: 0.625rem 1rem;
  margin: 2.375rem auto 0;
}

.rsvp-seal-wrap {
  display: block;
  /* Desktop affordance only — a touch guest gets no hover and needs none,
     because on a phone the seal is the thing under their thumb. */
  transition: transform var(--dur-ui) var(--ease);
}

.rsvp-open:hover .rsvp-seal-wrap,
.rsvp-open:focus-visible .rsvp-seal-wrap {
  transform: scale(1.04);
}

/* ⚠️ THE FLOAT IS ON THE IMAGE AND THE HOVER IS ON ITS WRAPPER, and they are
   split for a reason: `transform` is ONE property, so an animation and a hover
   state on the same element overwrite each other and the seal would snap flat
   the moment a cursor touched it. Two elements, one transform each, no
   conflict.

   2.8s and --ease are not chosen here — they are the hero's scroll cue and the
   entrance hint's drift, exactly. There are now three idle animations on this
   site and they keep one tempo rather than beating against each other. Read
   the note on .seal-hint in §15 before changing this number.

   THE SHADOW IS STATIC WHILE THE TRANSFORM MOVES. A drop-shadow that breathed
   with the float would be more physically honest and would repaint the element
   every frame for the life of the page; transform alone stays on the
   compositor. Same reasoning §4 gives for baking the deckle into a mask rather
   than running a live SVG filter: this site does not spend a mid-range phone's
   paint budget on decoration.

   --ink at 0.13, derived the way --rule and --ink-soft are. It is the one
   shadow on this page that is doing real work: a wax seal is a physical object
   resting on paper, which is exactly the case the dialog panel's hairline
   comment above rules a shadow OUT for. */
.rsvp-seal {
  /* 8rem on a phone, 10rem from about 727px up, and no media query to keep in
     step with anything. The width/height attributes in the markup carry the
     intrinsic 420x442 so the box is reserved before the file arrives and the
     section does not jump when it does. */
  width: clamp(8rem, 22vw, 10rem);
  height: auto;
  filter: drop-shadow(0 5px 7px rgb(58 46 37 / 0.13));
  animation: seal-float 2.8s var(--ease) infinite;
}

/* Both ends at rest, so §14's global "run it once at 0.01ms" clamp lands the
   seal exactly where it belongs instead of mid-air. Same contract the six
   timeline icons' float is written to. */
@keyframes seal-float {
  0%, 100% { transform: translateY(0)       scale(1);     }
  50%      { transform: translateY(-0.6rem) scale(1.015); }
}

/* The hint stays STILL while the seal moves. A caption bobbing with its
   subject reads as the whole thing sliding; a fixed label under a floating
   object is what makes the object look like it is floating.

   --brass-dim at FULL opacity. §15's .seal-hint fades 0.6 -> 1 as part of its
   drift; this one may not, because it is not animated and a permanent 0.75
   would sit below AA (see the contrast note in §1). */
.rsvp-tap {
  white-space: nowrap;
  font-size: 0.6875rem;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
}

.js .rsvp-dialog { display: none; }

/* z-index 90 is chosen, not arbitrary: the nav and the audio toggle are 20 and
   the entrance is 100. Below the envelope, above everything else — a guest can
   never end up with the RSVP form painted over their arrival. */
.js .rsvp-dialog.is-open {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: grid;
  place-items: center;
  /* The gutter doubles as notch clearance. max(), not a bare env(), because
     env() is 0 on every device without a cutout and the panel would then be
     flush against the trim on a laptop. */
  padding:
    max(1rem, env(safe-area-inset-top))
    max(1rem, env(safe-area-inset-right))
    max(1rem, env(safe-area-inset-bottom))
    max(1rem, env(safe-area-inset-left));
  background: rgb(58 46 37 / 0.55);
  opacity: 0;
  transition: opacity var(--dur-ui) ease;
}

/* .is-open paints it, .is-shown animates it in. js/main.js §8 applies the two
   one after the other with a forced style flush between them, rather than a
   frame apart — read the note there before changing either class name. */
.js .rsvp-dialog.is-open.is-shown {
  opacity: 1;
}

/* ⚠️ SAFETY, NOT STYLE. js/main.js drops .is-shown the instant the guest asks
   to leave and only removes .is-open after the fade, so for that 1.2s there is
   a full-screen element at opacity 0 lying over the page. Without this it
   would swallow every tap on the invitation underneath — the exact failure
   §15's teardown note describes. It costs nothing at open: .is-shown is applied
   in the same task as .is-open, so this state never lasts a whole frame. */
.js .rsvp-dialog.is-open:not(.is-shown) {
  pointer-events: none;
}

/* THE PANEL SCROLLS, THE PAGE DOES NOT.

   max-height: 100% — of the padded grid area, so the panel can never be taller
   than the space it is centred in and the centring can never push its top edge
   off screen. That is the trap in every `place-items: center` overlay: an item
   taller than its container overflows in BOTH directions, and the half above
   the fold is unreachable. Capping the item instead of scrolling the container
   avoids it outright.

   overscroll-behavior: contain is what .entrance uses `touch-action: none` for
   — stopping a drag at the end of this panel from rubber-banding the locked
   page behind it. The entrance can forbid touch outright because nothing
   inside it scrolls; this form is taller than a phone screen, so it must not. */
.js .rsvp-dialog.is-open .rsvp-panel {
  display: flex;
  flex-direction: column;
  /* The same 34rem measure as .section-inner--narrow (§4). A form is read one
     line at a time. */
  width: min(100%, 34rem);
  max-height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: clamp(1.5rem, 5vw, 2.5rem);
  background: var(--paper);
  /* A hairline, not a shadow. Elevation by drop-shadow reads as a card
     floating over a photograph; this page is paper laid on paper, and the
     scrim is already doing the separating. */
  border: 1px solid var(--rule);
  opacity: 0;
  transform: translateY(1.25rem);
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}

.js .rsvp-dialog.is-open.is-shown .rsvp-panel {
  opacity: 1;
  transform: none;
}

/* ⚠️ THE PANEL IS A SCROLL CONTAINER, SO NOTHING IN IT MAY SHRINK TO FIT.
   A flex item defaults to flex-shrink: 1, and a column flex container whose
   content is taller than its max-height squashes its children BEFORE it agrees
   to scroll. The form survives that — its fields have a min-content height to
   push back with — but the close button is a bare box, and it was measured
   collapsing from 44px to 24px the moment a guest asked for four children.

   A 44x24 target fails the tap floor, and it fails it on the control whose
   entire job is "let me out". Nothing here shrinks; the panel scrolls instead,
   which is what overflow-y is for. */
.js .rsvp-dialog.is-open .rsvp-panel > * {
  flex: none;
}

/* Tighter than the 2.5rem the form takes in the page: in here the close button
   sits above it and is doing that spacing already. */
.js .rsvp-dialog.is-open .rsvp-form {
  margin-top: 1rem;
}

/* ---- the way out ---- */

.rsvp-close { display: none; }

/* STICKY, NOT ABSOLUTE. The panel is the scroll container, so an absolutely
   positioned close button scrolls away with the form and a guest four fields
   down has to scroll back up to find it. Escape and the backdrop still work,
   but "the way out is off screen" is not a thing to ship on the one dialog on
   this site.

   The --paper disc is invisible against the panel's own ground and only shows
   itself where form content passes underneath. border-radius: 50% is already
   the vocabulary here — .radio-mark and .envelope-seal are both circles. */
.js .rsvp-dialog.is-open .rsvp-close {
  position: sticky;
  top: 0;
  z-index: 1;
  align-self: flex-end;
  display: flex;
  align-items: center;
  justify-content: center;
  /* --tap is the floor for a control a guest is meant to hit under pressure. */
  width: var(--tap);
  height: var(--tap);
  /* Pulled back into the panel's padding so the glyph sits in the corner
     rather than a whole tap target away from it. The target itself does not
     shrink — only where it is drawn moves. */
  margin: -0.5rem -0.5rem 0 0;
  border-radius: 50%;
  background: var(--paper);
  color: var(--brass-dim);
  font-size: 1.5rem;
  line-height: 1;
  /* No 300ms tap delay on the control whose whole job is "let me out". */
  touch-action: manipulation;
}

/* ---- phone: a country code and a number on one row ---- */

/* flex-end, not baseline: the two controls must share a bottom rule, and it is
   the rule a guest sees as "one field", not the text baseline. Both carry §11's
   own padding, so the baselines line up anyway. */
.phone-row {
  display: flex;
  align-items: flex-end;
  gap: 1rem;
}

.phone-number {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
}

/* THE SELECT IS AN UNDERLINE INPUT THAT HAPPENS TO OPEN A LIST. Every metric
   below is copied from `.field input` so the two sit on one line with one rule
   under them.

   -webkit-appearance: none is not tidiness. Left alone, iOS gives a select a
   rounded grey native chrome that looks nothing like the rest of this form —
   and taking the chrome off takes the caret with it, so it has to be redrawn.
   It is the same drawing idiom as the section swashes and the timeline icons:
   one weight, round caps, no fill, with --brass-dim baked into the data URI
   because a data URI cannot read a custom property. */
.phone-cc {
  flex: 0 0 auto;
  min-height: var(--tap);
  padding: 1.5rem 1.25rem 0.5rem 0;
  font: inherit;
  color: var(--ink);
  background-color: transparent;
  border: 0;
  border-bottom: 1px solid var(--rule-strong);
  border-radius: 0;
  -webkit-appearance: none;
  appearance: none;
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none' stroke='%237E6224' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  /* Anchored to the BOTTOM, so it tracks the text rather than the box: the
     row's height is set by the tallest control and the caret would drift if it
     were centred. */
  background-position: right 0.125rem bottom 1.15rem;
  background-size: 10px 6px;
  transition: box-shadow var(--dur-ui) ease, border-color var(--dur-ui) ease;
}

.phone-cc:focus {
  border-bottom-color: var(--brass-dim);
  box-shadow: inset 0 -1px 0 var(--brass-dim);
}

/* ---- adults, children, and an age per child ---- */

/* The block that goes away as a unit when a guest declines. [hidden]'s
   display: none (§2) beats this, so hiding it also closes the flex gap. */
#field-party {
  display: flex;
  flex-direction: column;
  gap: 2rem;
}

/* Two short controls per row at every width. "Adults" and "Παιδιά" are both
   short enough at the label's 0.75 scale to sit in half of a 390px phone's
   panel, and stacking them would make an already tall form taller. */
.party-row,
.child-ages {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.25rem 1.5rem;
}

/* No children, no rows — and, more to the point, no 2rem gap where the rows
   would be. Same job for the error below it, which is [hidden] until it has
   something to say, and for the no-JS ages field, which js/main.js hides. */
.child-ages:empty {
  display: none;
}

/* The gap above it is already 2rem, which is the form's own rhythm; .field-error's
   own margin would make this one message sit further from its field than every
   other message on the site. */
#field-party > .field-error {
  margin-top: 0;
}

/* ---- after the reply ---- */

/* Replaces the button once a response has been sent, so closing the dialog
   does not erase the only evidence the guest replied. Quiet on purpose: it is
   a receipt, not an announcement — the announcement was the success panel. */
/* It keeps the §2 focus ring rather than suppressing it the way .entrance does
   (§15). This is what takes focus when the button that opened the dialog is
   the thing being replaced, and a keyboard guest who has just closed the form
   should be shown where they have landed. #rsvp-success works the same way. */
.rsvp-replied {
  margin-top: 2.75rem;
  text-align: center;
  font-size: 0.875rem;
  color: var(--ink-soft);
}

/* --------------------------------------------------------------------------
   12. FOOTER
   -------------------------------------------------------------------------- */

.footer {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  padding: var(--section-y) var(--pad-x);
  background: var(--paper-alt);
  border-top: 1px solid var(--rule);
  text-align: center;
}

/* --brass-dim is 5.3:1 on --paper-alt, so it is safe at this size and would
   be safe far smaller. No tracking — joining face. */
.footer-monogram {
  font-family: var(--font-script);
  font-size: 2.25rem;
  line-height: 1;
  color: var(--brass-dim);
}

.footer-date {
  font-size: 0.875rem;
  letter-spacing: 0.16em;
}

.footer .lang-toggle {
  margin-top: 1rem;
}

/* --------------------------------------------------------------------------
   13. SCROLL REVEALS

   IntersectionObserver adds .is-revealed and unobserves (js/main.js §6).

   Gated behind .js, set by the inline script in <head>. Without that gate, a
   blocked or failed script would leave every [data-reveal] element at
   opacity 0 — the whole invitation, invisible, with no error a guest could
   see. The gate is in the head rather than in main.js so the hidden state is
   never painted and then undone.
   -------------------------------------------------------------------------- */

.js [data-reveal] {
  opacity: 0;
  transform: translateY(1.25rem);
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}

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

/* A row of cards enters the viewport as one block; a small stagger reads as
   arrival rather than as a flash. CSS-only — the observer stays one class. */
.js .guide-cards [data-reveal]:nth-child(2) { transition-delay: 90ms; }
.js .guide-cards [data-reveal]:nth-child(3) { transition-delay: 180ms; }

/* --------------------------------------------------------------------------
   14. REDUCED MOTION

   A real path, not an afterthought (constitution §6). `.no-motion` is the same
   contract applied by JS when a motion dependency is unavailable, so a failed
   library and a stated preference land in the identical, complete state:
   everything visible, nothing moving.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }

  html {
    scroll-behavior: auto;
  }
}

.no-motion *,
.no-motion *::before,
.no-motion *::after {
  animation: none !important;
  transition: none !important;
}

/* Anything a reveal would have started hidden must be visible on both paths.
   This is why the reveal is written as "hide, then show" rather than as an
   animation: there is a single declaration to override. */
.no-motion [data-reveal],
.reduced-motion [data-reveal] {
  opacity: 1 !important;
  transform: none !important;
}

/* The dots are the only thing tying an entry to the spine, and in the
   zig-zag layout that matters at every width — a missing dot at 360px leaves
   text floating beside a bare line with nothing to say which side of the
   spine it belongs to. There is no dot animation today; this is the standing
   guarantee that adding a scale-in later (phase 07/08) cannot take the dots
   away on either no-motion path. */
.no-motion .day-dot,
.reduced-motion .day-dot {
  opacity: 1 !important;
  transform: none !important;
  animation: none !important;
}

/* The same guarantee for the six event drawings. §9 already writes the float
   so that its first and last keyframes are both at rest, which means the
   duration clamp above lands them exactly where they belong — this is the
   belt to that braces, and it is what keeps a future opacity or scale on the
   float from quietly hiding an icon on a no-motion machine. */
.no-motion .day-icon,
.reduced-motion .day-icon {
  opacity: 1 !important;
  transform: none !important;
  animation: none !important;
}

/* The RSVP seal. Its keyframes already start and end at rest, so the global
   clamp above would land it correctly on its own — this is the standing
   guarantee that a future edit adding an opacity or a larger scale to
   seal-float cannot leave the section's only call to action shrunken or
   invisible on a no-motion machine. Same belt-and-braces as .day-icon above. */
.no-motion .rsvp-seal,
.reduced-motion .rsvp-seal {
  animation: none !important;
  transform: none !important;
}

/* PETALS ON THE NO-MOTION PATH.

   Handled explicitly rather than left to the global clamp above, because the
   clamp gets this WRONG in a way that is easy to miss. petal-drift starts and
   ends at opacity 0; run it once at 0.01ms and every petal completes
   instantly and settles at its base style — all five stacked invisibly at the
   top of the section. Not broken, but not the intent either: the decoration
   silently disappears.

   So three are parked at distinct heights as a still arrangement and the
   other two are removed. Three static petals read as scattered petals; five
   in a column read as a mistake. */
.no-motion .petal,
.reduced-motion .petal {
  animation: none !important;
  opacity: 0.3 !important;
}

.no-motion .petal--4,
.no-motion .petal--5,
.reduced-motion .petal--4,
.reduced-motion .petal--5 {
  display: none !important;
}

.no-motion .petal--1,
.reduced-motion .petal--1 { transform: translate3d(0, 18vh, 0) rotate(24deg) !important; }

.no-motion .petal--2,
.reduced-motion .petal--2 { transform: translate3d(0, 52vh, 0) rotate(-38deg) !important; }

.no-motion .petal--3,
.reduced-motion .petal--3 { transform: translate3d(0, 86vh, 0) rotate(12deg) !important; }

/* THE RSVP POPUP APPEARS AND DISAPPEARS, FULL STOP.

   The global clamp above would already reduce the fade to 0.01ms, which is
   invisible — but it leaves the panel's `transform: translateY(1.25rem)`
   opening state in place, and a panel that teleports 20px is a jump rather
   than a movement. Cancelling the transform outright is the difference between
   "no animation" and "an animation you cannot see".

   js/main.js §8 matches this from the other side: DIALOG_FADE_MS is 0 under
   reduced motion, so the teardown does not sit through 1.2s of nothing. */
.no-motion .rsvp-dialog,
.reduced-motion .rsvp-dialog,
.no-motion .rsvp-panel,
.reduced-motion .rsvp-panel {
  transition: none !important;
  transform: none !important;
}

/* --------------------------------------------------------------------------
   15. ENTRANCE — the envelope overlay

   The markup is in index.html; the <video> is created by js/main.js §4.

   THREE THINGS HERE ARE SAFETY, NOT STYLE, AND NONE OF THEM MAY BE SIMPLIFIED
   AWAY. Constitution §2: if a guest cannot get past the envelope, the
   invitation has failed at its only job.

     1. `.entrance { display: none }` is the DEFAULT, and only `.js` promotes
        it. Scripting off, blocked, or a stylesheet that loads while the script
        does not — every one of those ends with no overlay at all and a
        complete site, rather than a full-screen panel with no handler behind
        it. Same gate as the scroll reveals in §13, same reason.

     2. THE FAILSAFE ANIMATION. `.js` alone is not enough: main.js could load
        and then throw before it wires anything up, and the overlay would be
        painted and dead. So the promoted overlay starts a 12-second fuse the
        moment it is painted, and main.js defuses it with `.is-armed` as its
        first act. If the fuse ever burns down, CSS dissolves the overlay by
        itself and the guest is on the site. It should never fire; it costs
        nothing if it does not.

        It cannot lift the scroll lock, which is exactly why main.js applies
        `no-scroll` only AFTER arming — the two are ordered so that the state
        CSS cannot undo is never the state CSS is left holding.

     3. `body.no-scroll` lives here, applied by JS and by nothing else. It is
        never written into the markup: a lock the page ships with is a lock
        that survives a broken script.

   LAYOUT. .envelope-frame is a 720:1280 box sized to fit — letterboxed on
   --paper, never cropped. Cropping would be fine on a phone and wrong
   everywhere else, and worse, it would move the artwork under the seal button:
   `cover` on a viewport WIDER than 0.5625 (any tablet, any desktop) trims top
   and bottom, and the three per-cent values on .envelope-seal below would stop
   pointing at the wax seal. Contained, they are exact at every size with no
   measuring in JS.

   Above 768px this lands on the same rectangle as the hero's portrait card
   (§7): 768/1364 and 720/1280 agree to three decimals, so the two cards have
   the same silhouette and the dissolve at the end has nothing to travel.
   -------------------------------------------------------------------------- */

body.no-scroll {
  overflow: hidden;
}

.entrance {
  display: none;
}

.js .entrance {
  position: fixed;
  inset: 0;
  z-index: 100;          /* the nav and the audio toggle are 20 */
  display: grid;
  place-items: center;
  background: var(--paper);
  /* The overlay is the whole viewport and does not scroll, so this stops a
     drag from rubber-banding the locked page underneath it on iOS. */
  touch-action: none;
  animation: entrance-failsafe 1ms linear 12s forwards;
}

/* Set by main.js §4 before it touches anything else. See note 2 above. */
.entrance.is-armed {
  animation: none;
}

/* The one place on this site where a focus outline is suppressed, and it is
   the accepted exception rather than a shortcut. This element is
   tabindex="-1": it is not a control, it cannot be reached by Tab, and the
   only thing that ever focuses it is main.js moving focus into the dialog on
   arrival. Painting a ring around the entire viewport for that would say
   nothing to anyone. Every control inside it keeps the §2 focus style — the
   seal shows its ring the moment a guest actually Tabs to it. */
.entrance:focus,
.entrance:focus-visible {
  outline: none;
}

/* `visibility` and `pointer-events` are what actually free the guest;
   `opacity` is only so it does not vanish on a frame boundary. */
@keyframes entrance-failsafe {
  to {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
}

.envelope-frame {
  position: relative;
  /* 720/1280 = 0.5625, so a full-height card is 56.25svh wide. svh, not vh:
     the mobile toolbars crop vh and the card would overflow the screen. */
  width: min(100%, 56.25svh);
  aspect-ratio: 720 / 1280;
  max-height: 100svh;
  /* The poster is a background here rather than the video's `poster`
     attribute, for the same reason the hero's is (§7): one code path. The
     still is present whether or not a video is ever attached, so the
     Save-Data path, the 404 path and the mid-play `error` path all land on
     the same picture — and the seal stays visible and tappable on all three. */
  background-image: url("../assets/envelope-poster.jpg");
  background-size: cover;
  background-position: center;
}

/* ⚠️ z-index IS LOAD-BEARING HERE, not tidiness. main.js APPENDS the video to
   the frame, so it comes after the seal button in DOM order — and two
   positioned siblings with no z-index paint in that order. Without these two
   declarations the video covers the button: the hint disappears the moment
   the first frame decodes, and the wax seal a guest is being asked to tap is
   behind a video. It is invisible in testing until the video actually loads,
   which is exactly what makes it worth a comment. */
.envelope-video {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  /* The frame is already the video's own aspect, so this crops nothing. It is
     here to stop a rounding difference showing a hairline of poster. */
  object-fit: cover;
}

/* ⚠️ THESE THREE NUMBERS ARE MEASURED OFF THE ARTWORK, not chosen.
   In the 720×1280 frame the wax seal spans x 275–455 and y 625–805, so its
   centre is at 50.7% / 55.9% and its diameter is 180px = 25% of the width.
   26% gives the tap target a little more than the wax itself. Re-cut the
   video and all three move. */
.envelope-seal {
  position: absolute;
  z-index: 1;              /* over .envelope-video — see the note above it */
  left: 50.7%;
  top: 55.9%;
  transform: translate(-50%, -50%);
  width: 26%;
  min-width: var(--tap);
  aspect-ratio: 1;
  border-radius: 50%;
  /* NOT overflow:hidden — .seal-hint deliberately overhangs this box, and
     being a descendant is what keeps it inside the tap target. */
}

/* THE HINT IS THE ONLY AFFORDANCE ON THIS SCREEN, so it is the only thing here
   that animates.

   There used to be a gold ring pulsing outward from the seal's edge. It was
   removed: the wax in the artwork is already modelled with its own highlight
   and rim, and a second circle drawn on top of it read as a stray outline
   around the seal rather than as a halo behind it — worst of all in the frames
   where the pulse was near full opacity. The artwork does not need help being
   a wax seal. Deleting it also leaves the button with no painted box at all,
   which is fine: a <button> takes clicks whether or not it has a background.

   So the eye is drawn by the words instead, and they drift rather than pulse —
   slow motion, nothing snaps (constitution). 2.8s and --ease are the hero's
   scroll cue exactly, so the two idle animations on the site keep the same
   tempo instead of beating against each other.

   ⚠️ translateX(-50%) is the centring, and every keyframe has to carry it —
   `transform` is one property, so a keyframe that sets only translateY would
   silently drop the centring and shunt the text to the right. */
.seal-hint {
  position: absolute;
  /* 2rem, not the 1.1rem it shipped with: without the ring there is nothing
     between the words and the wax, and the artwork's own drop shadow under the
     seal needs clearing or the type looks stuck to it. */
  top: calc(100% + 2rem);
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: 0.6875rem;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
  animation: hint-drift 2.8s var(--ease) infinite;
}

@keyframes hint-drift {
  0%, 100% { transform: translateX(-50%) translateY(0);      opacity: 0.6; }
  50%      { transform: translateX(-50%) translateY(0.3rem); opacity: 1; }
}

/* Once the seal has been broken there is nothing left to tap. */
.entrance.is-playing .envelope-seal {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.5s ease;
}

/* Armed by .is-playing and delayed 1.5s: an escape hatch for a guest who does
   not want five seconds of envelope, offered only once there is something to
   escape from. Never a way to skip the tap itself — the tap is what unlocks
   the music. */
.envelope-skip {
  position: absolute;
  /* TUCKED AGAINST THE CARD, not against the window. Half the card is
     min(50%, 28.125svh) — the same two terms .envelope-frame is sized by — so
     its right edge sits at 50% + that, and this insets 2.5rem from it. On a
     phone the card IS the window and the max() floor takes over. 2.5rem, not
     1.5rem, because above 768px the outer 6% of the card is feathered and the
     label should not sit in the fade. Bottom-right of a 1920px window left it
     stranded half a screen from the envelope it belongs to. */
  right: max(var(--pad-x), calc(50% - min(50%, 28.125svh) + 2.5rem));
  bottom: 1.5rem;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  min-height: var(--tap);
  padding-inline: 0.75rem;
  font-size: 0.6875rem;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--brass-dim);
  /* `visibility`, not just `opacity`: an invisible button that already takes
     clicks is a trap of its own kind — a guest tapping the corner of the
     artwork in the first second would skip an animation they never saw a
     control for. Hidden means untappable, so the two cannot drift apart. */
  visibility: hidden;
  opacity: 0;
  transition: opacity var(--dur-ui) ease;
}

/* THE 1.5s WAIT IS A JS TIMER (main.js §4), NOT A transition-delay HERE, and
   that is deliberate. This is a safety control: it is the way out for a guest
   whose patience or connection has run out, so whether it appears must not
   depend on the browser running a CSS transition. Chrome suspends transitions
   outright in a background tab — which is also what made the delay version of
   this untestable. A timer is verifiable; a delay was not. */
.entrance.skip-armed .envelope-skip {
  visibility: visible;
  opacity: 0.8;
}

/* The dissolve. The video ends on a golden bloom and the ground under it is
   --paper, which is what the hero opens on — so this reads as the light
   washing out into the site rather than as a panel being removed.
   main.js §4 removes the element after --dur; if the transition never fires
   (no-motion), the removal timer still does. */
.entrance.is-open {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur) var(--ease);
}

@media (min-width: 768px) {
  /* Feathered into the cream at both sides, exactly as the hero card is (§7),
     so the two cards dissolve into one another without an edge appearing or
     disappearing. Gentler than the hero's 12% because the envelope's own
     painted border sits closer to the trim. */
  .envelope-frame {
    -webkit-mask-image:
      linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
            mask-image:
      linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
  }
}

/* --------------------------------------------------------------------------
   16. PHOTO BANDS — the three engagement photos

   THE PROBLEM. The photos are neutral black & white, shot at night, high
   contrast. This page is warm cream and warm brown and holds no pure black
   and no neutral grey anywhere. Dropped in untreated they read as photographs
   pasted onto a themed page — the one thing on the invitation that came from
   somewhere else.

   THE FIX is a warm duotone that remaps the whole tonal range onto the
   palette: shadows to --ink, highlights to --paper. The photo's whites become
   the page itself, so each image dissolves into the paper at its highlights
   instead of sitting in a bright rectangle. Both couple photos are full of
   white cotton and white embroidery, so this does most of the integrating on
   its own — the feather below only has to deal with the edges.

   IT IS DONE IN CSS, NOT BAKED INTO THE WEBPs. Baking it would be simpler and
   cheaper, but it would freeze the palette into three binary files. Driven off
   the tokens, a palette retune carries the photos with it and nothing needs
   re-exporting. The files ship as plain grayscale (ffmpeg `format=gray`),
   which is also the neutral starting point the blend math assumes.
   -------------------------------------------------------------------------- */

.photo-band {
  /* Separated from the section above, but NOT from the one below: each band
     is the opening image of the section it introduces, and that section's own
     top padding provides the gap. A band with padding on both sides floats
     between two things instead of belonging to one. */
  padding-block: var(--section-y) 0;
}

/* .section + .section (§4) draws a torn edge above every section, which is
   right for the tear ABOVE a band — it separates it from the section it
   follows — and wrong for the one below, which would rip through what should
   read as a single block of photo-then-section.

   Equal specificity to the §4 rule (two classes each), so this wins on source
   order alone. It has to stay after §4. Was `border-top: 0` before phase 07;
   the edge is a pseudo-element now, so it is removed rather than zeroed. */
.photo-band + .section::before {
  content: none;
}

/* Band 3 is the last child of <main> and the footer is a sibling of <main>,
   so no combinator reaches from one to the other — hence :has(). Same job as
   the rule above: band 3 and the footer share --paper-alt and should read as
   one block. Where :has() is unsupported the hairline simply stays, which is
   the pre-photo appearance and not a defect. */
main:has(> .photo-band:last-child) + .footer {
  border-top: 0;
}

.photo-frame {
  position: relative;
  margin-inline: auto;

  /* MANDATORY. Without it the blend modes below reach past this element and
     tint the section background behind it. If you ever see the cream around a
     photo shift, this is the line that is missing or on the wrong element. */
  isolation: isolate;

  /* The shadow end of the ramp. */
  background: var(--ink);
}

/* Half native width, exactly — see the per-band widths below. */
.photo-frame img {
  display: block;
  width: 100%;
  height: auto;

  /* THE TUNABLE. Lifting blacks off #000 up to --ink costs these night shots
     some depth, and this buys it back. Range 1.05–1.20; judge it on photo 1,
     which is the darkest of the three. Too high and her hair blocks up into a
     flat shape. */
  filter: grayscale(1) contrast(1.08);

  /* Against the --ink backdrop: image-black becomes --ink, image-white stays
     white. Half the ramp. */
  mix-blend-mode: screen;
  position: relative;
  z-index: 0;
}

/* The other half: multiply by the ground pulls white down to the page colour
   and warms everything under it. Composed with the screen above, the ramp runs
   #392C22 → the ground, with no neutral grey left anywhere in it. */
.photo-frame::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: var(--photo-ground);
  mix-blend-mode: multiply;
  pointer-events: none;
}

/* THE FEATHER — a soft frame in the ground colour, painted over the finished
   duotone, so the photo dissolves into the paper instead of ending at a hard
   rectangle.

   WHY THIS AND NOT mask-image, which §7 and §15 use for the hero and envelope
   cards. Those are full-height panels feathered left and right only. A card
   needs all four edges, and a mask cannot do four edges in one gradient — a
   radial is a vignette, which fades the corners hardest and the edge midpoints
   barely, and two linear masks need mask-composite, whose standard keyword
   (intersect) and WebKit keyword (source-in) disagree. Two overlaid gradients
   in the ground colour compose by ordinary alpha blending, need no prefix, and
   fade to exactly the colour behind them. The corners get both, which reads as
   a soft corner rather than a mistake.

   THESE VALUES WERE TUNED BY EYE, and the top one is the one with a story. It
   started at 3%, on the reasoning that all three photos carry content hard
   against the top edge — the camera cropped both their heads in 1 and 2 — so a
   fade there costs the most. That was wrong, and photo 1 showed it: above his
   head is night sky, so a shallow fade has to cross the entire distance from
   near-black to cream in 19px, and a fast gradient between two far-apart tones
   reads as exactly the hard line it was meant to prevent. The fix is counter-
   intuitive — MORE fade, not less. At 10% the sky dissolves properly, and the
   hair it also softens looks like a printed vignette rather than a crop.

   The lesson generalises: size the fade to the CONTRAST it has to cross, not
   to how precious the content under it is.

   ⚠️ Re-tune if the photos are ever replaced. Photo 3 is the one to check —
   her trailing foot sits almost on the left edge and 9% is as far as the sides
   can go before it is lost. */
.photo-frame {
  --feather-x: 9%;
  --feather-top: 10%;
  --feather-bottom: 10%;
}

.photo-frame::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(to right,
      var(--photo-ground), transparent var(--feather-x),
      transparent calc(100% - var(--feather-x)), var(--photo-ground)),
    linear-gradient(to bottom,
      var(--photo-ground), transparent var(--feather-top),
      transparent calc(100% - var(--feather-bottom)), var(--photo-ground));
}

/* THE WIDTHS ARE HALF EACH FILE'S NATIVE PIXEL WIDTH, and that is the whole
   sizing rule. The sources are small for the web (724, 854 and 1206px wide),
   so displaying them at half gives a clean 2× retina image and makes upscaling
   structurally impossible rather than merely unlikely. This is also why the
   bands are contained cards and not full-bleed: 1206px cannot cross a 1440px
   screen without visibly softening.

   min(100%, …) is a ceiling, not a width — a 390px phone shows them narrower
   still, never wider. The two portrait cards land near the width of the hero
   and envelope cards, so the page keeps one card idiom throughout.

   REPLACING A PHOTO MEANS RECOMPUTING ITS WIDTH HERE and its width/height
   attributes in index.html. They are not decorative. */
.photo-band--1 .photo-frame { width: min(100%, 362px); }  /*  724 / 2 */
.photo-band--2 .photo-frame { width: min(100%, 427px); }  /*  854 / 2 */
.photo-band--3 .photo-frame { width: min(100%, 603px); }  /* 1206 / 2 */

/* --photo-ground is per band because the highlight end of the ramp has to be
   the ground the photo actually sits on. Get this wrong and the photo's whites
   land a shade off the section behind them, which shows up as a faint
   rectangle exactly where the feather was supposed to remove one. Keep each
   value in step with the band's section-paper / section-alt class. */
.photo-band--1 { --photo-ground: var(--paper); }
.photo-band--2 { --photo-ground: var(--paper); }
.photo-band--3 { --photo-ground: var(--paper-alt); }
