/* =============================================================================
   Beth & Harry - one stylesheet for the whole site.
   =============================================================================

   Both pages link this file, so the tokens, the typeface and the shared
   furniture exist once. Previously each page carried its own copy inline and the
   two had already started to drift.

   Source order is load-bearing in places. `.panel > *:not(.panel-bg)` is (0,2,0)
   and out-specifies bare classes, and several overrides here win only by coming
   later in the file. The comments at those rules say so. Moving a rule up can
   silently do nothing.

   Layout is scoped so the two pages cannot reach into each other: the
   save-the-date lives under .page / .panel, the address form under .sheet.
   ============================================================================= */

/* ---- Typeface -----------------------------------------------------------
   Self-hosted so every guest sees the same face. The old stack started with
   Iowan Old Style, which does not exist on Android, so most phones fell back
   to Noto Serif. SIL Open Font License.
   Paths are absolute: a relative url() in a stylesheet resolves against the
   stylesheet, so "assets/fonts/..." would look for /assets/assets/fonts/...
   -------------------------------------------------------------------------- */
@font-face {
  font-family: "WedSerif";
  src: url("/assets/fonts/lora-regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "WedSerif";
  src: url("/assets/fonts/lora-italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: swap;
}

/* ---- Tokens ------------------------------------------------------------- */
:root {
  /* Bath stone in daylight, rather than yew shade at dusk. */
  --stone:     #efebe0;
  --ink:       #232b1d;
  --ink-soft:  #414937;
  --accent:    #3d5836;
  --accent-rgb: 61 88 54;
  --veil:      239 235 224;
  --mottle:    214 208 190;
  /* Only the form uses this, for validation messages. */
  --error:     #7a2f21;
  --error-rgb: 122 47 33;

  --display: "WedSerif", Georgia, serif;
  --utility: "Avenir Next", Avenir, "Segoe UI", system-ui, -apple-system, sans-serif;
  --fs: 0.94;

  --pad: clamp(2.25rem, 6vw, 5rem);
  --mount: clamp(.7rem, 1.7vw, 1.3rem);
}

/* ---- Base --------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }


body {
  margin: 0;
  background: var(--stone);
  color: var(--ink);
  font-family: var(--display);
  -webkit-font-smoothing: antialiased;
}


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

/* =============================================================================
   Save-the-date  (/)
   ============================================================================= */
/* Self-hosted so every guest sees the same face. The old stack started with
   Iowan Old Style, which does not exist on Android — most phones were
   falling back to Noto Serif. SIL Open Font License. */


.page {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: minmax(7rem, 1fr) auto;
  min-height: 100vh; min-height: 100svh;
}
@media (min-width: 900px) {
  .page { grid-template-columns: minmax(0,1fr) minmax(0,1.05fr); grid-template-rows: 1fr; }
  .photo { grid-column: 2; grid-row: 1; }
  .panel { grid-column: 1; grid-row: 1; }
}

.photo { position: relative; margin: 0; min-height: 0; background: #dfd9c8; overflow: hidden; }
.photo picture, .photo img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
/* The waterfall mist flattens this shot; a little contrast and warmth gives it
   back some snap without reading as a filter. */
/* Vertical framing measured off the source frame rather than guessed: heads top
   out at 35% of the image, Harry's chin sits at 72% and Beth's at 78%. That band
   is what must survive `cover` cropping.

   Two things were wrong. The framing put Beth's chin at 88% of the photo box,
   leaving barely a tenth of the height beneath it - and on a phone that edge is
   where the browser toolbar sits, so her chin went first. Worse, on a short
   screen the photo row collapsed to 85px, and the face band needs about 0.32x
   the box width to fit at all, so on a 320px-wide phone it could not fit however
   it was positioned: heads were cropped at -3% and her chin at 118%.

   So both are set from the measurements rather than by eye. Solved across eight
   phone viewports: 63% here plus the min-height below is the only combination
   where every size keeps head tops AND chins, worst case +9px of margin. Going
   higher (68% was tried) buys chin clearance by cropping heads on small screens.
   What gets cropped instead is waterfall, which is background. */
.photo img { object-fit: cover; object-position: 49% 63%; animation: settle 1.7s cubic-bezier(.16,.84,.28,1) both;
             filter: contrast(1.07) saturate(1.1); }
@media (min-width: 900px) { .photo img { object-position: 48% 50%; } }

.panel {
  position: relative; isolation: isolate;
  display: flex; flex-direction: column; justify-content: center;
  gap: clamp(1.6rem, 3.6vh, 2.9rem);
  padding: var(--pad);
  background: var(--stone);
  overflow: hidden;
}
.panel-bg { z-index: -1; }
.panel-bg, .panel-bg img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.panel-bg img { object-fit: cover; object-position: 62% 52%; animation: settle 2s cubic-bezier(.16,.84,.28,1) both; }

/* A pale wash instead of a dark scrim: the house reads as a soft impression
   behind the words rather than a shadow. */
.panel::before {
  content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background:
    /* mottling and daylight, listed first so they sit over the wash */
    radial-gradient(58% 42% at 74% 22%, rgb(var(--mottle) / .5), transparent 70%),
    radial-gradient(48% 36% at 26% 62%, rgb(var(--mottle) / .38), transparent 72%),
    linear-gradient(168deg, rgb(255 253 246 / .48) 0%, transparent 42%,
                      rgb(var(--mottle) / .32) 100%),
    /* Held dense to the small-text edge (measured: 46% on desktop), then it
       falls away sharply so the house reads across the right of the panel.
       Past that only the names reach, and large text needs 3:1 where it has 11. */
    linear-gradient(100deg,
      rgb(var(--veil) / .94) 0%,
      rgb(var(--veil) / .90) 48%,
      rgb(var(--veil) / .52) 66%,
      rgb(var(--veil) / .24) 84%,
      rgb(var(--veil) / .14) 100%),
    linear-gradient(to bottom, rgb(var(--veil) / .3), transparent 45%);
}

/* Stacked: palest at the join, so the photograph above dissolves into it. */
@media (max-width: 899px) {
  .panel::before {
    background:
      /* mottling and daylight, listed first so they sit over the wash */
      radial-gradient(58% 42% at 74% 22%, rgb(var(--mottle) / .5), transparent 70%),
      radial-gradient(48% 36% at 26% 62%, rgb(var(--mottle) / .38), transparent 72%),
      linear-gradient(168deg, rgb(255 253 246 / .48) 0%, transparent 42%,
                              rgb(var(--mottle) / .32) 100%),
      /* Small text reaches 54% on a phone; hold to there, then open up. */
      /* Small text stops at 54%, so past 62% the veil can go nearly clear and
         the house becomes properly present rather than a hint. */
      linear-gradient(102deg,
        rgb(var(--veil) / .95) 0%,
        rgb(var(--veil) / .92) 58%,
        rgb(var(--veil) / .44) 76%,
        rgb(var(--veil) / .10) 100%),
      linear-gradient(to top, rgb(var(--veil) / .62) 0%, transparent 24%);
  }
  /* The photograph now closes the page, so the join is at its TOP edge and the
     dissolve runs downward from the panel. Faces sit low in this crop, so
     fading from above leaves them untouched. */
  .photo::after {
    content: ""; position: absolute; inset: 0 0 auto 0; z-index: 1; pointer-events: none;
    height: clamp(80px, 18vh, 150px);
    background: linear-gradient(to bottom,
      rgb(var(--veil) / .99) 0%, rgb(var(--veil) / .74) 30%,
      rgb(var(--veil) / .3) 64%, rgb(var(--veil) / 0) 100%);
  }

  /* Type first, photograph second. */
  .page { grid-template-rows: auto minmax(7rem, 1fr); }

  /* A floor for the photograph, so their faces cannot be squeezed out.
     `cover` needs roughly 0.32x the box width of height to fit the face band,
     and 1fr alone gave it as little as 85px on a short screen - not enough at any
     object-position. 38vw guarantees the band fits with margin to spare.
     It only bites at 320px wide, where the page then scrolls about 37px. That is
     the trade: a little scroll on the smallest phones instead of a cropped face
     on them. At 360px and wider the row is already taller than this and nothing
     changes. */
  .photo { min-height: 38vw; }

  /* Landscape on a phone is the one shape where the floor above is not enough.
     The row is as wide as the screen and only a few hundred pixels tall, so the
     photograph gets a slot around 2.6:1 to fit a 4:3 frame into: measured at
     888x411, only 31%-82% of the picture survived, leaving Beth's chin 25px off
     the bottom edge. Nothing was technically cropped and it still read as cut
     off, because the entire lower fifth of the frame was gone.

     So in landscape the photograph is given a 3:2 slot instead, capped at one
     screenful — `svh` so the cap is the height with the browser's toolbar
     showing, not without it. On a phone the cap is what applies, which makes the
     picture exactly fill the screen when you reach it: a deliberate full frame
     rather than a strip with the bottom missing. The page scrolls a little
     further for it, which it was doing anyway — the panel alone is taller than a
     landscape screen. */
  @media (orientation: landscape) {
    .photo { min-height: min(66vw, 100vh); min-height: min(66vw, 100svh); }
    /* And 67% rather than the 63% portrait uses, which is where the face band
       lands dead centre in a slot this shape. Derived, not tried: the band runs
       35%-78% of a frame drawn 666px tall in a 411px box, so 233 - (411-286)/2
       = 171px of the 255px crop belongs above it. The ratio holds at every
       landscape size because the geometry is self-similar. */
    .photo img { object-position: 49% 67%; }
  }

  /* On a very short screen the panel plus the photo's floor overflowed by a
     hair; let the photograph give up a little more room rather than scroll. */
  @media (max-height: 700px) {
    .page { grid-template-rows: auto minmax(5rem, 1fr); }
    .panel { gap: clamp(1rem, 2.2vh, 1.6rem); }
  }
  .panel { grid-row: 1; }
  .photo { grid-row: 2; }
}

/* NOTE for future edits: this rule is (0,2,0), so it out-specifies a bare
   class like `.voice` or `.grain`. New children need `.panel > .thing` to beat
   it. And overrides appended later in this sheet win on source order at equal
   specificity — so an override placed ABOVE the rule it targets does nothing.
   Both traps have silently swallowed edits here more than once. */
.panel > *:not(.panel-bg) { position: relative; z-index: 1; max-width: 32rem; }

.emblem { display: block; animation: rise .9s ease-out .15s both; width: clamp(42px, 7.6vw, 58px); height: auto; color: var(--accent); }

.eyebrow {
  margin: 0; font-family: var(--utility);
  font-size: .68rem; font-weight: 600; letter-spacing: .3em;
  text-transform: uppercase; color: var(--ink-soft);
}

/* Gentler: no negative tracking, more line height, and the weight left alone
   rather than pushed. */
.names {
  margin: 0;
  font-size: calc(clamp(2.9rem, 9.2vw, 5.7rem) * var(--fs));
  font-weight: 400; line-height: 1.1; letter-spacing: 0;
  text-wrap: balance;
  animation: rise .9s ease-out .28s both;
}
.amp { font-size: .8em; font-style: italic; color: var(--accent); margin: 0 .22em 0 .16em; }

.facts { margin: 0; display: flex; flex-direction: column; gap: 1.5rem; animation: rise .9s ease-out .42s both; }
.fact { display: grid; gap: .5rem; padding-top: 1.25rem; border-top: 1px solid rgb(var(--accent-rgb) / .24); }
.fact dt {
  font-family: var(--utility); font-size: .65rem; font-weight: 600;
  letter-spacing: .26em; text-transform: uppercase; color: var(--ink-soft);
}
.fact dd { margin: 0; font-size: calc(clamp(1.15rem, 1.55vw, 1.4rem) * var(--fs)); line-height: 1.55; }
.fact dd .lead { display: block; }
/* The venue link is dressed as plain address text, because there is one thing to
   do on this page — send us an address — and while the map link was the accented,
   underlined one it was the loudest thing in the panel, drawing the eye away
   from the ask below it.

   The underline is only hidden, not removed: transparent at rest, accented on
   hover, the same way `.home` on the form page announces itself. That leaves the
   ask as the only thing that LOOKS like a link, while still letting a mouse find
   the map. A transparent border on an inline element costs no layout, so the
   resting page is unchanged to the pixel.

   Touch has no hover, so a phone gets no cue here at all — which is the trade
   accepted for a single, unmistakable call to action. The focus ring is NOT
   removed: it comes from the shared a:focus-visible rule and is the only thing
   telling a keyboard user they have landed on a link. */
.fact a {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  padding-bottom: .08em;
  transition: border-color .25s ease;
}
/* .38 rather than the .5 `.home` uses: a secondary link's hover state should
   not out-weigh the resting underline on the page's one call to action. */
.fact a:hover { border-bottom-color: rgb(var(--accent-rgb) / .38); }


@media (max-width: 899px) {
  .panel { gap: clamp(1rem, 2.2vh, 1.7rem); padding: clamp(1.6rem, 5.2vw, 2.3rem); }
  .facts { gap: 1rem; }
  .fact { padding-top: .85rem; }
}

@keyframes settle { from { opacity: 0; transform: scale(1.055); } to { opacity: 1; transform: scale(1); } }
@keyframes rise   { from { opacity: 0; transform: translateY(11px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .photo img, .panel-bg img, .emblem, .names, .facts, .voice, .sprig { animation: none; }
  .panel > .sprig { opacity: .46; }
  .fact a { transition: none; }
}

/* ---- Grain and mount --------------------------------------------------
   The daylight rewrite left the panel a flat wash. Mottling and a sheen are
   folded into the wash above; this adds the weathering grain and the hairline
   mount the original stone had. Selector is `.panel > .grain` rather than bare
   `.grain` because `.panel > *:not(.panel-bg)` would otherwise out-specify it
   and force position:relative. */
.panel > .grain {
  position: absolute;
  inset: 0;
  z-index: 0;
  max-width: none;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.055 0.09' numOctaves='4' stitchTiles='stitch' seed='7'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 180px 180px;
  opacity: .5;
  mix-blend-mode: overlay;
}

.panel::after {
  content: "";
  position: absolute;
  inset: var(--mount);
  z-index: 0;
  border: 1px solid rgb(var(--accent-rgb) / .26);
  pointer-events: none;
}

/* Closed on all four sides: the block sits inside a complete frame rather
   than bleeding into the photograph on one edge. */

/* ---- Corner foliage ---------------------------------------------------
   Sprigs hang from the mount's corners and cross the frame line, so the
   planting overhangs the frame rather than sitting politely inside it.
   Selector is `.panel > .sprig` because `.panel > *:not(.panel-bg)` would
   otherwise out-specify it and force position:relative. Decorative only, so
   hidden from assistive tech. */
.panel > .sprig {
  position: absolute;
  z-index: 0;
  max-width: none;
  width: clamp(76px, 21%, 148px);
  height: auto;
  color: var(--accent);
  opacity: .46;
  pointer-events: none;
  animation: unfurl 1.4s ease-out .55s both;
}

/* Wider than tall: it runs along the top edge, clear of the emblem below. */
.sprig-tl { top: var(--mount); left: var(--mount); width: clamp(96px, 27%, 186px); }
.sprig-br { bottom: var(--mount); right: var(--mount); }

@keyframes unfurl {
  from { opacity: 0; transform: scale(.97); }
  to   { opacity: .46; transform: none; }
}

/* The date is the only fact on the page anyone has to act on, so it carries
   more weight than the postcode. Scoped under `.fact dd` so the panel's own
   child rule cannot out-specify it. */
.fact dd .big {
  display: block;
  font-size: 1.5em;
  line-height: 1.25;
  letter-spacing: -.005em;
}

/* The closing line, broken by hand so each line is shorter than the one above
   — a tapered rag, the way an invitation would set it. The breaks do the
   shaping, so max-width only has to clear the longest line; `em` keeps that
   true at every size rather than only the one it was measured at.

   `.panel > .voice` rather than `.voice`: the panel's own child rule is
   (0,2,0) and would otherwise hold max-width at 32rem, letting this line run
   past the dense part of the wash onto the house's dark foliage. */
.panel > .voice {
  margin: 0;
  max-width: 15em;
  /* Italic sets the message apart from the information above it. Lora's italic
     is already self-hosted for the ampersand, so this costs no extra bytes. */
  font-style: italic;
  font-size: calc(clamp(1.15rem, 1.6vw, 1.42rem) * var(--fs));
  line-height: 1.5;
  color: var(--ink);
  animation: rise .9s ease-out .52s both;
}


/* The way through to the address form. Set in the serif at reading size so it
   belongs to the message above it rather than presenting itself as a button —
   nobody is being sold anything.

   `.panel > .ask` is (0,2,0), exactly matching `.panel > *:not(.panel-bg)`, so
   this block MUST stay below that rule or source order would drop it. */
.panel > .ask {
  margin: 0;
  font-size: calc(clamp(.95rem, 1.15vw, 1.05rem) * var(--fs));
  line-height: 1.5;
  color: var(--ink-soft);
  animation: rise .9s ease-out .62s both;
}
/* The one link on the page that is dressed as one. It is the page's only
   action, so it gets the accent and the underline, and it is now the only thing
   in the panel wearing them — the treatment that used to sit on the venue link
   just above. Still set in the serif at reading size rather than as a button:
   nobody is being sold anything. */
.ask a {
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid rgb(var(--accent-rgb) / .38);
  padding-bottom: .08em;
  transition: border-color .25s ease;
}
.ask a:hover { border-bottom-color: var(--accent); }

/* Reduced motion, corrected for specificity.
   The block earlier in this sheet lists `.voice` and `.sprig` bare, at (0,1,0).
   Their animations are declared on `.panel > .voice` and `.panel > .sprig` at
   (0,2,0), and a media query adds no specificity — so those two have been
   animating regardless of the preference. Restating them here as `.panel > x`
   matches the specificity, and being later in the sheet settles the tie. */
@media (prefers-reduced-motion: reduce) {
  .panel > .voice, .panel > .sprig, .panel > .ask { animation: none; }
}

/* Must come AFTER the base .voice rule: same specificity, so source order
   decides. Nested inside the compaction block earlier in the sheet it lost,
   and the page kept overflowing by 5px on a short screen. */
@media (max-width: 899px) and (max-height: 700px) {
  .panel > .voice { font-size: calc(1.04rem * var(--fs)); line-height: 1.45; }
  .panel > .ask   { font-size: calc(.92rem * var(--fs)); }
}

/* Screens that are BOTH narrow and short — 320x568 is the one that matters.
   Narrow wraps the copy onto extra lines and short leaves no room for them, and
   adding the address link tipped the page 8px into scrolling. Measured across
   twelve viewports; this was the only one that needed it.

   Last in the sheet on purpose: the two earlier blocks that set `.panel` gap
   and `.page` rows are the same (0,1,0) specificity, so this only wins by
   source order. Moving it above either of them would silently do nothing. */
@media (max-width: 899px) and (max-height: 620px) {
  .page  { grid-template-rows: auto minmax(4rem, 1fr); }
  .panel { gap: clamp(.8rem, 1.9vh, 1.35rem); }
}

/* Short viewports in the TWO-COLUMN layout. Every compaction tier above is
   gated on `max-width: 899px`, so from 900px up a short viewport got no
   compaction at all and the panel simply overflowed. That is not a rare shape:
   a 1366x768 laptop leaves roughly 660px of viewport once browser chrome is
   taken, and a large phone in landscape is wider than 900px too.

   Measured: at 1024x600 this recovers the overflow the closing lines added and
   then some. Placed last so it beats the base `.panel`, `.names` and
   `.panel > .voice` rules, which it matches on specificity. */
@media (min-width: 900px) and (max-height: 700px) {
  .panel { gap: clamp(.9rem, 2vh, 1.5rem); padding: clamp(1.5rem, 4vw, 2.8rem); }
  .names { font-size: calc(clamp(2.4rem, 6.2vw, 3.9rem) * var(--fs)); }
  .facts { gap: 1rem; }
  .fact  { padding-top: .85rem; }
  .panel > .voice { font-size: calc(1.06rem * var(--fs)); line-height: 1.45; }
  .panel > .ask   { font-size: calc(.95rem * var(--fs)); }
}

/* =============================================================================
   Address form  (/address/)
   ============================================================================= */
/* Same faces and tokens as the save-the-date, so this reads as the same
   wedding rather than a form bolted on. Kept as its own sheet rather than
   shared: this page needs none of the diptych layout, and the save-the-date's
   wash is tuned to measurements that do not apply here. */


.sheet {
  position: relative; isolation: isolate;
  min-height: 100vh; min-height: 100svh;
  display: flex; flex-direction: column;
  justify-content: center; align-items: center;
  padding: clamp(1.7rem, 5.4vw, 3.2rem);
  overflow: hidden;
}

/* The house, but far more veiled than on the save-the-date: form labels and
   field borders have to stay legible over it, so it reads as warm texture
   rather than as a photograph. Same URLs as the save-the-date, so for anyone
   arriving from there it is already in cache and costs nothing. */
.sheet-bg { z-index: -1; }
.sheet-bg, .sheet-bg img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.sheet-bg img { object-fit: cover; object-position: 62% 52%; }

.sheet::before {
  content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background:
    radial-gradient(58% 42% at 74% 18%, rgb(var(--mottle) / .42), transparent 70%),
    radial-gradient(48% 36% at 22% 68%, rgb(var(--mottle) / .34), transparent 72%),
    linear-gradient(168deg, rgb(255 253 246 / .5) 0%, transparent 44%, rgb(var(--mottle) / .3) 100%),
    linear-gradient(104deg, rgb(var(--veil) / .97) 0%, rgb(var(--veil) / .95) 62%, rgb(var(--veil) / .93) 100%);
}

.sheet > .grain {
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.055 0.09' numOctaves='4' stitchTiles='stitch' seed='7'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 180px 180px;
  opacity: .5;
  mix-blend-mode: overlay;
}

.sheet::after {
  content: ""; position: absolute; inset: var(--mount); z-index: 0;
  border: 1px solid rgb(var(--accent-rgb) / .26);
  pointer-events: none;
}

.sheet > .sprig {
  position: absolute; z-index: 0;
  bottom: var(--mount); right: var(--mount);
  width: clamp(76px, 19%, 138px); height: auto;
  color: var(--accent); opacity: .4;
  pointer-events: none;
}

.card {
  position: relative; z-index: 1;
  width: 100%; max-width: 33rem;
  display: flex; flex-direction: column;
  gap: clamp(1.4rem, 3.2vh, 2.1rem);
  animation: rise-card .8s ease-out .1s both;
}

.home {
  font-family: var(--utility);
  font-size: .68rem; font-weight: 600; letter-spacing: .3em;
  text-transform: uppercase; color: var(--ink-soft);
  text-decoration: none;
  align-self: start;
  border-bottom: 1px solid transparent;
}
.home:hover { border-bottom-color: rgb(var(--accent-rgb) / .5); }

h1 {
  margin: 0;
  font-size: clamp(2rem, 6.4vw, 2.9rem);
  font-weight: 400; line-height: 1.15; letter-spacing: 0;
  text-wrap: balance;
}

.intro {
  margin: 0; max-width: 26em;
  font-size: 1.06rem; line-height: 1.55; color: var(--ink-soft);
}

form { display: flex; flex-direction: column; gap: 1.35rem; }

.field { display: flex; flex-direction: column; gap: .45rem; }
.field label {
  font-family: var(--utility);
  font-size: .65rem; font-weight: 600; letter-spacing: .26em;
  text-transform: uppercase; color: var(--ink-soft);
}

input[type="text"], input[type="email"], textarea {
  width: 100%;
  font-family: var(--display);
  font-size: 1.05rem; line-height: 1.5;
  color: var(--ink);
  background: rgb(255 253 246 / .78);
  border: 1px solid rgb(var(--accent-rgb) / .32);
  border-radius: 2px;
  padding: .68rem .8rem;
  transition: border-color .2s ease, background-color .2s ease;
}
input[type="text"]:hover, input[type="email"]:hover, textarea:hover { border-color: rgb(var(--accent-rgb) / .5); }
input[type="text"]:focus, input[type="email"]:focus, textarea:focus {
  outline: 2px solid var(--accent); outline-offset: 1px;
  border-color: var(--accent);
  background: rgb(255 253 246 / .95);
}
textarea { resize: vertical; min-height: 7.5rem; }

/* Inline validation, written into the page by assets/form.js.
   The label and the border are tinted as well as the sentence being added, so on
   a phone — where the message may be the only part of the field scrolled into
   view — there is something to see wherever you happen to be looking. */
.field-error {
  margin: 0;
  font-size: .92rem;
  line-height: 1.45;
  color: var(--error);
}
/* Belt and braces: the `hidden` attribute is honoured by a UA rule, which any
   author `display` on .field-error would beat. Stating it here means adding one
   later cannot silently un-hide every message on the form. */
.field-error[hidden] { display: none; }

.field.is-invalid label { color: var(--error); }
/* (0,2,1), the same as `input[type="text"]:focus` above — so this MUST stay
   below that rule or source order would drop it. */
.field.is-invalid input[type="text"],
.field.is-invalid input[type="email"],
.field.is-invalid textarea {
  border-color: rgb(var(--error-rgb) / .55);
  background: rgb(var(--error-rgb) / .04);
}
/* One step higher again, so the focus ring is not swallowed by the tint: a
   guest sent to this field to fix it needs to see that they have arrived. */
.field.is-invalid input[type="text"]:focus,
.field.is-invalid input[type="email"]:focus,
.field.is-invalid textarea:focus {
  outline-color: var(--error);
  border-color: var(--error);
}

/* Off-screen rather than display:none or hidden: bots skip anything obviously
   unrenderable, but happily fill a field that is merely out of view. */
.decoy {
  position: absolute; left: -9999px; width: 1px; height: 1px;
  overflow: hidden;
}

/* Town and postcode share a row: splitting the address for autofill should not
   make the form look longer than the one-box version it replaced. Postcode is
   given just enough room for the longest UK format and no more. */
.field-row { display: flex; gap: 1rem; align-items: flex-start; }
.field-row .field { flex: 1 1 auto; min-width: 0; }
.field-row .field-postcode { flex: 0 0 8.5rem; }
@media (max-width: 400px) {
  /* Below this the two side by side get too cramped to type in comfortably.

     align-items must be restored to stretch: the row sets flex-start, which in
     column direction is the CROSS axis, so both fields shrank to their content
     width. The postcode had an explicit width:100% and looked right; the town
     field did not, and sat at the intrinsic width of a text input — about half
     the form — on every phone narrower than this. */
  .field-row { flex-direction: column; gap: 1.35rem; align-items: stretch; }
  .field-row .field-postcode { flex: 1 1 auto; }
}

/* No min-height: with appearance="interaction-only" the widget is absent for
   almost everyone, and reserving 65px would leave a permanent blank gap above
   the Send button. When a challenge IS required it sizes itself.

   Turnstile renders in an iframe with a hard 300px minimum width that no
   attribute can reduce, and at a 320px viewport this column is 266px inside
   `overflow: hidden` — so left alone it would be clipped, not merely tight.
   Scaling is the only lever the widget offers. Height is left to the element
   rather than pinned, so the collapsed state really does collapse. */
@media (max-width: 375px) {
  .turnstile { transform: scale(.84); transform-origin: left top; }
}

.back {
  margin: 0;
  font-size: .95rem;
  line-height: 1.5;
}
.back a {
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid rgb(var(--accent-rgb) / .3);
  padding-bottom: .08em;
  transition: border-color .25s ease;
}
.back a:hover { border-bottom-color: var(--accent); }

.sheet .nojs {
  margin: 0;
  padding-left: .9rem;
  border-left: 2px solid rgb(var(--accent-rgb) / .4);
  font-size: .95rem; line-height: 1.55;
  color: var(--ink-soft);
}

.send {
  align-self: start;
  font-family: var(--utility);
  font-size: .72rem; font-weight: 600; letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--stone);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: 2px;
  padding: .9rem 2.1rem;
  cursor: pointer;
  transition: opacity .2s ease;
}
.send:hover { opacity: .88; }
.send:disabled { cursor: default; }

/* Sending state. The button reports progress itself rather than needing a
   separate status line, and stays the same size so the layout does not jump when
   the label changes from "Send" to "Sending…". */
.send[aria-busy="true"] {
  opacity: 1;
  padding-left: 3.1rem;
  position: relative;
}
.send[aria-busy="true"]::before {
  content: "";
  position: absolute;
  left: 1.9rem;
  top: 50%;
  width: .78rem;
  height: .78rem;
  margin-top: -.39rem;
  border: 2px solid rgb(239 235 224 / .35);
  border-top-color: var(--stone);
  border-radius: 50%;
  animation: spin .7s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

/* A spinner is the one place motion carries meaning, so it is not removed for
   reduced motion - it is slowed to a step so it still reads as "working" without
   continuous rotation. */
@media (prefers-reduced-motion: reduce) {
  .send[aria-busy="true"]::before { animation: spin 2.4s steps(8, end) infinite; }
}
.send:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.errors {
  margin: 0; padding: .9rem 1rem .9rem 1.1rem;
  list-style: none;
  display: flex; flex-direction: column; gap: .4rem;
  border-left: 2px solid var(--error);
  background: rgb(var(--error-rgb) / .07);
  color: var(--error);
  font-size: .98rem; line-height: 1.5;
}

.privacy {
  margin: 0;
  font-size: .9rem; line-height: 1.55;
  color: var(--ink-soft); opacity: .88;
  max-width: 30em;
}


@keyframes rise-card { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }
  input[type="text"], input[type="email"], textarea, .send, .back a, .ask a, .fact a { transition: none; }
}
