/* =========================================================
   invite.css — shared chrome for every party page.

   A party page sets three custom properties and writes its
   copy. Nothing else should need to change:

     :root{ --ground: …; --ink: …; --accent: …; }

   Palette defaults below are deliberately neutral (the cream
   on dark from yerazik.com) so an unstyled page still looks
   intentional rather than broken.
========================================================== */

:root{
  --ground: #0d0f12;        /* page */
  --ink:    #f2efe8;        /* type */
  --accent: #B497CF;        /* rules, the RSVP button, emphasis */
  --rule:   rgba(242,239,232,.24);

  --sans: "Helvetica Neue", Helvetica, -apple-system, BlinkMacSystemFont,
          "Segoe UI", Inter, Arial, sans-serif;
  --display: "Bebas Neue", Impact, var(--sans);

  --ease: cubic-bezier(.22,1,.36,1);
}

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

html{ overflow-x:hidden; }

body{
  margin:0;
  /* One gutter for the whole page. padding-block so the 24px side
     gutter below can never be zeroed by a shorthand. */
  padding-block:clamp(2.5rem,8vh,6rem);
  padding-inline:max(24px, env(safe-area-inset-left), env(safe-area-inset-right));
  min-height:100vh;
  min-height:100svh;       /* ignores the phone's collapsing toolbars */
  display:flex;
  align-items:center;
  justify-content:center;

  background:var(--ground);
  color:var(--ink);
  font-family:var(--sans);
  font-size:clamp(.95rem,.85rem + .4vw,1.0625rem);
  line-height:1.55;
  -webkit-font-smoothing:antialiased;
  text-rendering:optimizeLegibility;
}

.invite{
  position:relative;
  z-index:6;               /* top of the scene stack — see "the scene" below */
  width:100%;
  max-width:46rem;
  /* One centred column. Blocks with their own measure below pair this
     with margin-inline:auto, or they would sit centred-text but
     left-aligned as boxes. */
  text-align:center;
}

/* ---------- the name, carried over from the main site ---------- */

.wordmark{
  margin:0;
  font-family:var(--display);
  font-weight:400;
  /* Scales from phone to desktop without a breakpoint. */
  font-size:clamp(3.5rem,14vw,9rem);
  line-height:.84;
  letter-spacing:.01em;
  text-transform:uppercase;
}

.kicker{
  margin:0 0 clamp(1rem,3vh,1.75rem);
  font-size:.75rem;
  letter-spacing:.22em;
  text-transform:uppercase;
  color:var(--accent);
}

.lede{
  margin:clamp(1.25rem,4vh,2rem) auto 0;
  max-width:34ch;
  font-size:1.125em;
}

/* A headline that is a sentence rather than a word. The .wordmark clamp
   is sized for one short word; a two-line phrase needs a quieter scale
   and a line-height that does not collide the descenders. */
.wordmark--phrase{
  margin-inline:auto;
  font-size:clamp(2.25rem,8vw,4.75rem);
  line-height:.95;
  max-width:24ch;
}

/* The date, in the headline's face a few sizes down. It is the second
   thing on the page and the only other line in --display, which is what
   makes the order read headline → date → what the night is. No clamp
   this wide: it has to stay well under .wordmark--phrase or the two
   display lines start competing. */
.when{
  margin:clamp(1.25rem,3.5vh,2rem) 0 0;
  font-family:var(--display);
  font-weight:400;
  font-size:clamp(1.5rem,5vw,2.5rem);
  line-height:1;
  letter-spacing:.02em;
  text-transform:uppercase;
}

/* What the night consists of, set like the kicker it replaces. */
.billing{
  margin:clamp(1.5rem,4vh,2.25rem) 0 0;
  font-size:.8rem;
  letter-spacing:.2em;
  text-transform:uppercase;
  /* Anchors .billing-hint, which hangs under the whole line rather than
     under the term, so it cannot run off the edge on a phone. */
  position:relative;
}

/* Under the date the billing is its subtitle, not the next thing down,
   so it loses most of the gap the shared rule gives it. */
.when + .billing{ margin-top:.55rem; }

/* "Costume Contest" carries the rules of the contest. Chrome-free like
   the address until the pointer arrives — the cursor is the only hint
   that there is something to read — and reachable by tab, because on a
   touch screen there is no hover and focus is the way in. */
.billing-term{
  cursor:help;
  /* The focus ring is the one piece of chrome it does get. */
  border-radius:2px;
  outline-offset:.25em;
}

/* The same label the address and the cats wear — see .place-hint. It is
   centred on .billing rather than on the term, which sits at the end of
   the line: centred on the term it would hang off the right edge at
   phone width. */
.billing-hint{
  position:absolute;
  left:50%;
  top:calc(100% + .5rem);
  translate:-50% -.3rem;

  padding:.3em .62em;
  background:rgb(var(--scrim-rgb,14 9 5) / .85);
  color:var(--ink);
  font-size:.7rem;
  line-height:1.5;
  letter-spacing:.14em;
  text-transform:uppercase;
  white-space:nowrap;
  text-shadow:none;

  opacity:0;
  transition:opacity .25s var(--ease), translate .25s var(--ease);
  /* Never let the label eat the hover from the term under it. */
  pointer-events:none;
}

.billing-term:hover .billing-hint,
.billing-term:focus-visible .billing-hint{ opacity:1; translate:-50% 0; }

/* When and where. No rule above it: the whole page is one voice in one
   colour, and a hairline across a painted night only chops it up. The
   spacing does the separating instead. */
.logistics{
  margin:clamp(1.75rem,5vh,2.5rem) 0 0;
  /* Set like .billing: the address is the page's other small aside, and
     the two lines carry the same voice rather than one being a sentence
     and the other a caption. */
  font-size:.8rem;
  letter-spacing:.2em;
  text-transform:uppercase;
}

/* ---------- the address ---------- */

/* The address copies itself when you press it — see shared/place.js,
   which is also where it learns to stop being an address. Until then it
   has to go on reading as the second line of the logistics block, so it
   gets no chrome at all: a button sitting in the middle of that
   sentence, boxed or ruled, would read as a second RSVP. Nothing marks
   it until the pointer arrives, and then the label does. */
.place-copy{
  position:relative;
  display:inline-block;
  margin:0;
  padding:0;
  border:0;
  background:none;
  color:inherit;
  font:inherit;
  line-height:inherit;
  /* A <button> is where inheritance stops: the UA sheet resets
     letter-spacing, text-transform and text-shadow on form controls, so
     `font:inherit` alone left the address untracked, in sentence case
     and without the invitation's halo while .logistics said otherwise.
     Hand all three back, or the address and .billing — set from the
     same values — still do not match. */
  letter-spacing:inherit;
  text-transform:inherit;
  text-shadow:inherit;
  text-align:center;
  text-decoration:none;
  cursor:pointer;
}

/* The same label the cats wear — see .cat-name. One page, one way of
   putting a word beside the thing it is about.

   Under the address rather than over it, which is the one difference:
   the cats stand in open sky, but this sits on the second line of a
   paragraph, and above it is the date. Below is the gap before the RSVP
   button, so the label drops into space that is already empty.

   No text-shadow: the invitation's halo is for cream type over fog, and
   this sits on its own scrim already. */
.place-hint{
  position:absolute;
  left:50%;
  top:calc(100% + .45rem);
  translate:-50% -.3rem;

  padding:.3em .62em;
  background:rgb(var(--scrim-rgb,14 9 5) / .85);
  color:var(--ink);
  font-size:.7rem;
  line-height:1.5;
  letter-spacing:.14em;
  text-transform:uppercase;
  white-space:nowrap;
  text-decoration:none;
  text-shadow:none;

  opacity:0;
  transition:opacity .25s var(--ease), translate .25s var(--ease);
  /* Never let the label eat the press from the address under it. */
  pointer-events:none;
}

.place-copy:hover .place-hint,
.place-copy:focus-visible .place-hint{ opacity:1; translate:-50% 0; }

/* Just pressed. Held open for a moment by place.js, because on a phone
   there is no hover to have read the label with, and "Copied" is the
   only confirmation a tap gets. */
[data-place][data-copied] .place-hint{ opacity:1; translate:-50% 0; }

/* ---------- what / where / when ---------- */

.details{
  margin:clamp(2rem,6vh,3.25rem) auto 0;
  display:grid;
  justify-content:center;
  /* Label column sizes to its content, value takes the rest; both
     collapse to one column on a narrow screen. */
  grid-template-columns:auto 1fr;
  gap:.85rem 2rem;
}

.details dt{
  font-size:.7rem;
  letter-spacing:.18em;
  text-transform:uppercase;
  color:var(--accent);
  padding-top:.3em;       /* optical baseline match against dd */
}

.details dd{
  margin:0;
  font-size:1.0625em;
}

@media (max-width:30rem){
  .details{ grid-template-columns:1fr; gap:.2rem 0; }
  .details dd{ margin-bottom:1rem; }
}

/* ---------- RSVP ---------- */

.cta{
  display:inline-block;
  margin-top:clamp(2rem,6vh,3rem);
  padding:.85em 2em;

  border:1px solid var(--accent);
  background:transparent;
  color:var(--accent);

  font:inherit;
  font-size:.8rem;
  letter-spacing:.18em;
  text-transform:uppercase;
  text-decoration:none;

  transition:background .35s var(--ease), color .35s var(--ease);
}

.cta:hover,
.cta:focus-visible{
  background:var(--accent);
  color:var(--ground);
}

.cta:focus-visible{
  outline:2px solid var(--accent);
  outline-offset:4px;
}

/* ---------- the RSVP form ---------- */

.rsvp{
  margin:clamp(2.5rem,7vh,4rem) 0 0;
}

/* ---------- the RSVP card ---------- */

/* One object in two states. Closed, the card is only its head, which is
   button-sized and reads as the [ RSVP ] button; open, that same pane has
   grown down and out around the form. It is built as one box for exactly
   that reason — a button above a separate panel has nothing to expand
   *into*.

   A thin dark pane, rather than the painting straight behind the fields:
   a column of labels and boxes needs a quieter ground than a lit hillside
   with fog crossing it. Thin on purpose — the scene still reads through,
   which is the only reason this is a card and not a panel. Tinted with
   --scrim-rgb, the same dark as the legibility wash over the hill, so a
   party page retints both from one line.

   No border: a hairline here would be the one line this design has
   otherwise got rid of, and the fill already says where the edge is. */
.rsvp-card{
  /* How much card there is. The scene is supposed to carry on through it,
     so this stays low enough to read the hill behind the fields — it is
     the one number to turn if the form ever needs a firmer ground. */
  --card-wash: .45;

  /* Width comes from whatever is showing — the head alone when closed,
     the form when open — so the box expands on its own, with nothing to
     measure and nothing to keep in sync. min() keeps it inside a phone. */
  display:flex;
  flex-direction:column;
  align-items:stretch;
  width:fit-content;
  max-width:min(32rem, 100%);
  margin-inline:auto;

  background:rgb(var(--scrim-rgb) / var(--card-wash));
  border-radius:0;            /* square, like the inputs and the button */

  /* The page is centred; the form inside is not. A column of fields you
     have to read and fill is a different job from a line of display copy,
     and it wants a left edge to run down. */
  text-align:left;
}

/* The head is the button. It stretches to the full width of the card, so
   the whole top of the pane is the hit target with no dead strip beside
   the word. */
.rsvp-card-head{
  padding:clamp(1rem,4vw,1.35rem) clamp(1.25rem,5vw,2.25rem);
  min-height:44px;

  border:0;
  background:none;
  color:var(--ink);

  font:inherit;
  font-size:.8rem;
  letter-spacing:.18em;
  text-transform:uppercase;
  text-align:left;
  cursor:pointer;

  transition:background .3s var(--ease);
}

/* Deepens rather than lights up: the head already sits on the card's own
   wash, so a second layer of the same dark is the cheapest way to say
   pressable without introducing a colour. Derived from --card-wash so the
   two cannot drift apart. */
.rsvp-card-head:hover{
  background:rgb(var(--scrim-rgb) / calc(var(--card-wash) * .6));
}

/* Inset: the head sits flush in the corner of the card, so an outset ring
   would be drawn outside the pane it belongs to. */
.rsvp-card-head:focus-visible{
  outline:2px solid var(--accent);
  outline-offset:-3px;
}

/* Padded on all four sides: by the time the body is on screen the head
   has gone, so there is nothing above it to supply the top. */
.rsvp-card-body{
  padding:clamp(1.25rem,5vw,2.25rem);
}

/* The open itself is in rsvp.js, not here. A box going from the size of a
   button to the size of a form cannot be done in CSS without hard-coding
   one of the two — the whole point of width:fit-content above is that
   neither is known in advance — so the script measures the card before
   and after and animates between the two rects. */

/* Inside the card the column is left-aligned and fills the width, so the
   blocks that were centred for the page hand that back. */
.rsvp-card .rsvp-form .field,
.rsvp-card .rsvp-status{
  margin-inline:0;
  max-width:none;
}

.rsvp-card .rsvp-form .choice{ justify-content:flex-start; }

.rsvp-heading{
  margin:0 0 clamp(1.25rem,3vh,1.75rem);
  font-family:var(--display);
  font-weight:400;
  font-size:clamp(1.75rem,6vw,2.5rem);
  line-height:1;
  letter-spacing:.02em;
  text-transform:uppercase;
}

/* Every field — div or fieldset — is laid out the same way, so the
   form reads as one column regardless of control type. */
.rsvp-form .field{
  margin:0 auto clamp(1.25rem,3vh,1.75rem);
  padding:0;
  border:0;
  max-width:28rem;
}

.rsvp-form label,
.rsvp-form legend{
  /* Centred, so the legend needs the full width — a fieldset legend is
     shrink-to-fit by default and would sit hard left. */
  display:block;
  width:100%;
  margin:0 0 .6rem;
  padding:0;
  font-size:.7rem;
  letter-spacing:.18em;
  text-transform:uppercase;
}

.rsvp-form input[type="text"],
.rsvp-form textarea{
  display:block;
  width:100%;
  padding:.7em .85em;

  border:1px solid var(--rule);
  border-radius:0;
  background:rgba(0,0,0,.2);
  color:var(--ink);

  font:inherit;
  /* Under 16px, iOS zooms the page in on focus and never zooms back
     out. This is the one place the type scale does not get a vote. */
  font-size:max(1em, 16px);
  transition:border-color .25s var(--ease);
}

.rsvp-form textarea{ resize:vertical; min-height:4.5em; }

.rsvp-form input[type="text"]::placeholder,
.rsvp-form textarea::placeholder{ color:var(--ink); opacity:.35; }

.rsvp-form input[type="text"]:focus-visible,
.rsvp-form textarea:focus-visible{
  outline:none;
  border-color:var(--accent);
}

/* Radio rows. The label is the hit target, so the whole row is
   tappable on a phone. */
.rsvp-form .choice{
  display:flex;
  align-items:center;
  justify-content:center;
  gap:.7rem;
  margin:0 0 .35rem;
  /* Pads the row out to a thumb-sized tap target without moving the
     label text off its line. */
  padding-block:.35rem;
  min-height:44px;

  /* overrides the uppercase label styling above */
  font-size:1em;
  letter-spacing:normal;
  text-transform:none;
  color:var(--ink);
  cursor:pointer;
}

.rsvp-form .choice input[type="radio"]{
  flex:none;
  width:1.05em;
  height:1.05em;
  margin:0;
  accent-color:var(--accent);
  cursor:pointer;
}

.rsvp-form .choice:hover span{ color:var(--accent); }

.rsvp .field-note{
  margin:.6rem 0 0;
  font-size:.8rem;
  color:var(--ink);
  opacity:.55;
}

.rsvp .field-note a{ color:var(--accent); }

button.cta{ cursor:pointer; min-height:44px; }

@media (max-width:30rem){
  button.cta{ width:100%; }
}

button.cta:disabled{ opacity:.5; cursor:default; }

.rsvp-status{
  margin:1.1rem auto 0;
  max-width:34ch;
  min-height:1.5em;        /* reserve the line so nothing jumps */
  font-size:.9rem;
}

.rsvp-status[data-kind="ok"],
.rsvp-status[data-kind="found"]{ color:var(--accent); }

.rsvp-status[data-kind="error"]{ color:#ff8a7a; }

.footnote{
  margin:clamp(2rem,5vh,2.5rem) 0 0;
  font-size:.8rem;
  color:var(--ink);
  opacity:.55;
}

.footnote a{ color:inherit; }

/* =========================================================
   the scene — painted night, stars, fog, foreground

   Opt-in. A party page that wants one sets --scene-bg and
   --scene-fg, then drops the layers into the markup:

     <div class="scene"          aria-hidden="true"></div>
     <div class="stars"          aria-hidden="true"> … </div>
     <div class="fog fog--high"  aria-hidden="true"><i></i></div>
     <div class="fog fog--back"  aria-hidden="true"><i></i></div>
     <div class="foreground"     aria-hidden="true"></div>
     <div class="fog fog--front" aria-hidden="true"><i></i></div>

   Stacking, bottom to top:

     .invite          6   the invitation
     .wish            5   the shooting star and the line it writes
     .fog--front      5   thin wash creeping over the rocks
     .cats            4   a cat per guest, standing on the hill
     .foreground      3   cut-out hill + grass (carries the scrim)
     .fog--back       2   the body of the bank, behind the hill
     .fog--high       2   a thinner stratum drifting above the bank
     .stars           1
     .scene           0   the painting

   Splitting the fog around the foreground is the whole trick: the
   same bank then reads as sitting *in* the landscape rather than
   pasted over the top of it. The cats go into that same gap, above
   the hill they stand on and under the wash that drifts across it.

   .wish ties with .fog--front on z-index and comes after it in the
   markup, which is how it wins: it carries type, and type that a
   wash can creep over is type nobody reads. Its own section is at
   the bottom of this file; a page that leaves the markup out gets
   no shooting star and nothing else changes.

   Every layer is fixed and hidden from screen readers, and all of
   them are inert except the cats, which answer to a hover. A page
   that sets none of this is unaffected.
========================================================== */

:root{
  --scene-bg: none;
  --scene-fg: none;

  /* Horizontal focal point, shared by both painted layers so they
     stay in register. Overridden in portrait — see below. */
  --scene-x: 50%;

  /* Height of the fog bank, measured up from the bottom. It has to
     clear the crest of the hill, which sits higher in portrait. */
  --fog-h: 68svh;
  --fog-blur: 46px;
  /* Base cycle for the fog. Every wash is a multiple of this, so the
     whole bank speeds up or slows down from one line. Smaller = faster. */
  --fog-pace: 13s;
  /* How much paint is in the wash, and the dial to turn for more or less
     weather. This is deliberately set heavy: the fog is meant to be the
     loud part of the scene, not a hint. Faint fog also cannot visibly
     move — whatever it does, the change is too small to notice. */
  --fog-mix: .45;

  /* The legibility wash over the lower half, so cream type stays
     readable where the painting goes pale. Space-separated channels
     so a party page can retint it in one line. */
  --scrim-rgb: 14 9 5;
}

.scene,
.stars,
.fog,
.foreground,
.cats,
.wish{
  position:fixed;
  inset:0;
  pointer-events:none;
  -webkit-user-select:none;
  user-select:none;
}

/* Fixed elements rather than background-attachment:fixed on the body —
   iOS rescales a canvas-propagated fixed background to the document
   height, which crops the hill off the bottom of a scrolling page. */
.scene{
  z-index:0;
  background:var(--scene-bg) var(--scene-x) bottom / cover no-repeat;
}

.foreground{
  z-index:3;
  background:var(--scene-fg) var(--scene-x) bottom / cover no-repeat;
}

/* Rides on the foreground because it needs the full viewport and has
   to sit above the hill, which is where the painting goes palest. */
.foreground::after{
  content:"";
  position:absolute;
  inset:0;
  background:linear-gradient(
    to bottom,
    rgb(var(--scrim-rgb) / 0)    0%,
    rgb(var(--scrim-rgb) / .10) 48%,
    rgb(var(--scrim-rgb) / .34) 78%,
    rgb(var(--scrim-rgb) / .46) 100%
  );
}

/* ---------- stars ---------- */

/* Hand-placed, not generated: every star carries its own position,
   size and opacity as inline custom properties. The mask fades the
   field out before it reaches the hill, so no star can land on the
   rocks however `cover` happens to crop the painting. */
/* The three in-keyframe properties are registered so `calc()` on them
   is dependable. An unregistered custom property reaches a keyframe as
   a raw token to be substituted, and older iOS Safari will not do
   arithmetic on the result — it drops the declaration and the star
   stops twinkling. Typed as numbers, it cannot. */
@property --o         { syntax:'<number>'; inherits:true; initial-value:0.7; }
@property --twinkle   { syntax:'<number>'; inherits:true; initial-value:0.5; }
@property --star-lift { syntax:'<number>'; inherits:true; initial-value:1.6; }
@property --star-pace { syntax:'<number>'; inherits:true; initial-value:1; }

.stars{
  z-index:1;
  --star-fade: 44%;
  --star-gone: 72%;

  /* The three dials for the whole field, the way --fog-pace works for
     the fog. Scale multiplies every hand-placed --s, pace multiplies
     every cycle (smaller = faster), lift is how far above its placed
     opacity a star is allowed to flare at the top of the breath. */
  --star-scale: 1.4;
  --star-pace: 1;
  --star-lift: 1.6;
  -webkit-mask-image:linear-gradient(to bottom,
    #000 0 var(--star-fade),
    rgba(0,0,0,.6) calc(var(--star-gone) - 12%),
    transparent var(--star-gone));
  mask-image:linear-gradient(to bottom,
    #000 0 var(--star-fade),
    rgba(0,0,0,.6) calc(var(--star-gone) - 12%),
    transparent var(--star-gone));
}

.star{
  position:absolute;
  left:var(--x);
  top:var(--y);
  width:calc(var(--s,2px) * var(--star-scale,1));
  height:calc(var(--s,2px) * var(--star-scale,1));
  opacity:var(--o,.7);
  /* Ivory, never pure white — it has to sit in the same paint box as
     the moon. No box-shadow either: a glow is the single fastest way
     to make this look like a screensaver instead of a painting. */
  background:#f3ece0;
  /* Lopsided radii, so each dot reads as a flicked ink mark rather
     than a perfect circle. */
  border-radius:62% 38% 55% 45% / 48% 60% 40% 52%;
}

.star:nth-of-type(3n)  { border-radius:45% 55% 40% 60% / 58% 42% 56% 44%; }
.star:nth-of-type(4n+1){ border-radius:55% 45% 62% 38% / 40% 56% 44% 60%; }

/* The bigger marks get a hair of blur, the way a loaded brush bleeds
   into the paper. */
.star--soft{ filter:blur(.35px); }

/* A few drawn as little crosses, like the ones inked by hand. The two
   arms are deliberately unequal and the whole thing sits off-square. */
.star--cross{
  width:auto;
  height:auto;
  background:none;
  border-radius:0;
  transform:rotate(var(--rot,0deg));
}

.star--cross::before,
.star--cross::after{
  content:"";
  position:absolute;
  top:50%;
  left:50%;
  background:#f3ece0;
}

.star--cross::before{
  width:calc(var(--s,7px) * var(--star-scale,1));
  height:1px;
  transform:translate(-50%,-50%);
}

.star--cross::after{
  width:1px;
  /* shorter arm — hand-drawn, not a glyph */
  height:calc(var(--s,7px) * .78 * var(--star-scale,1));
  transform:translate(-50%,-50%);
}

/* Every star breathes. Only the ones tagged --slow used to, which left
   two thirds of the sky perfectly still — and a single pulsing dot next
   to five dead ones reads as a glitch, not as weather.

   Cycle and phase are per-star: `--dur` and `--delay` can be set inline
   and win, otherwise the two counts below hand them out. Both ends of
   the breath are relative to the star's own --o, so a faint star stays
   faint relative to a bright one: --twinkle is how far down the dip
   goes, --star-lift how far above --o it is allowed to flare. The flare
   is what makes a twinkle read as a twinkle rather than a fade, and the
   min() keeps it legal when a bright star would overshoot 1. */
.star{
  animation:star-breathe var(--dur,1.2s) ease-in-out infinite alternate;
  animation-duration:calc(var(--dur,1.2s) * var(--star-pace,1));
  animation-delay:var(--delay,0s);
}

/* Lengths off a count of five, phases off a count of seven, so the pair
   only repeats every thirty-five stars — past the point where the eye
   finds the pattern. Nothing here is tuned to a particular star; it is
   just a spread. Remember `alternate` doubles each of these: a 0.9s
   length is a 1.8s round trip. */
.star:nth-of-type(5n+1){ --dur:.9s;  }
.star:nth-of-type(5n+2){ --dur:1.4s; }
.star:nth-of-type(5n+3){ --dur:1.1s; }
.star:nth-of-type(5n+4){ --dur:1.7s; }
.star:nth-of-type(5n)  { --dur:1s;   }

.star:nth-of-type(7n+1){ --delay:0s;   }
.star:nth-of-type(7n+2){ --delay:.7s;  }
.star:nth-of-type(7n+3){ --delay:.25s; }
.star:nth-of-type(7n+4){ --delay:1.1s; }
.star:nth-of-type(7n+5){ --delay:.45s; }
.star:nth-of-type(7n+6){ --delay:.9s;  }
.star:nth-of-type(7n)  { --delay:.1s;  }

/* The tagged ones keep the deep dip and run a little longer: these are
   the few you actually catch going out, against a sky that is otherwise
   only shimmering. Their lengths are inline in the page. */
.star--slow{ --twinkle:.18; }

@keyframes star-breathe{
  from{ opacity:calc(var(--o,.7) * var(--twinkle,.5)); }
  to  { opacity:min(1, var(--o,.7) * var(--star-lift,1.6)); }
}

/* ---------- fog ---------- */

/* Large soft washes, not a cloud system. Each is a blurred lopsided
   ellipse that never gets opaque enough to hide the painting underneath.
   The lopsided part matters: a perfect circle under a 46px blur is
   radially symmetric, so sliding it sideways barely changes a single
   pixel and the fog looks painted on. An irregular blob that drifts,
   turns and thickens actually reads as moving air. */
.fog{
  top:auto;
  height:var(--fog-h);
  /* The blobs overhang the viewport; the container clips them so they
     can never widen the page and let a phone pan sideways. */
  overflow:hidden;
  /* ...but that clip is a razor-straight horizontal cut, and as soon as a
     blob scales up into the top edge it draws a hard line across the
     scene. Fading the band out well before the container ends means the
     clip always lands where there is nothing left to cut. Also gives the
     bank the density gradient real fog has: thick at the ground, thinning
     with height. */
  -webkit-mask-image:linear-gradient(to top,
    #000 0 36%, rgba(0,0,0,.5) 68%, transparent 90%);
  mask-image:linear-gradient(to top,
    #000 0 36%, rgba(0,0,0,.5) 68%, transparent 90%);
}

.fog--back{
  z-index:2;
  --fog-mix:.62;
}

/* In front of the hill, so it has to be thinner — this is the wisp
   that spills over the rock, and it is the one the eye checks. */
.fog--front{
  z-index:5;
  height:calc(var(--fog-h) * .52);
  --fog-mix:.38;
}

.fog::before,
.fog::after,
.fog > i{
  content:"";
  position:absolute;
  background:rgba(214,208,196,var(--fog-mix));
  filter:blur(var(--fog-blur));
  /* Rasterise once and only transform it. Without this the blur is
     recomputed every frame, which is what makes fog like this stutter
     on a phone. */
  will-change:transform, opacity;
}

.fog--back::before{
  --fog-op:1;
  left:-22%;
  bottom:0;
  width:82%;
  height:52%;
  border-radius:54% 46% 62% 38% / 58% 52% 48% 42%;
  animation:fog-drift-a var(--fog-pace) ease-in-out infinite alternate;
}

.fog--back::after{
  --fog-op:.62;
  right:-26%;
  bottom:9%;
  width:74%;
  height:45%;
  border-radius:42% 58% 45% 55% / 50% 44% 56% 50%;
  animation:fog-drift-b calc(var(--fog-pace) * 1.45) ease-in-out infinite alternate;
}

/* Highest and thinnest — what keeps the bank from reading as a single
   blurred bar across the bottom of the screen. */
.fog--back > i{
  --fog-op:.42;
  left:6%;
  bottom:40%;
  width:54%;
  height:30%;
  border-radius:60% 40% 50% 50% / 44% 60% 40% 56%;
  filter:blur(calc(var(--fog-blur) * 1.3));
  animation:fog-drift-c calc(var(--fog-pace) * .78) ease-in-out infinite alternate;
}

/* The two in front drift against each other and against the pair behind,
   so nothing in the bank ever moves as one piece. Both are wide and flat
   and sit across the face of the rock rather than under it. */
.fog--front::before{
  --fog-op:.6;
  left:-20%;
  bottom:-30%;
  width:86%;
  height:110%;
  border-radius:46% 54% 55% 45% / 52% 48% 52% 48%;
  animation:fog-drift-b calc(var(--fog-pace) * 1.18) ease-in-out infinite alternate;
}

.fog--front > i{
  --fog-op:.5;
  right:-24%;
  bottom:-20%;
  width:80%;
  height:96%;
  border-radius:58% 42% 48% 52% / 46% 56% 44% 54%;
  animation:fog-drift-c calc(var(--fog-pace) * .64) ease-in-out infinite alternate;
}

/* A second stratum, thinner than the ground bank and reaching higher.
   It runs the full height of the scene rather than floating in a band:
   anchored to the bottom like the others, so its washes can drift right
   down to the foot of the screen, with only the top edge masked away. */
.fog--high{
  z-index:2;
  bottom:0;
  /* Taller than the bank below it. It is the thin one, so it can carry
     this much height without the sky closing in. */
  height:calc(var(--fog-h) * 1.34);
  --fog-mix:.3;
  --fog-blur:62px;
  -webkit-mask-image:linear-gradient(to top,
    #000 0 26%, rgba(0,0,0,.5) 62%, transparent 96%);
  mask-image:linear-gradient(to top,
    #000 0 26%, rgba(0,0,0,.5) 62%, transparent 96%);
}

/* Tall and wide, and hung below the bottom edge so there is no height at
   which this layer runs out. Slower than the bank beneath it — the lag is
   what gives the two any sense of depth. */
.fog--high::before{
  --fog-op:.8;
  left:-18%;
  bottom:-16%;
  width:92%;
  height:74%;
  border-radius:50% 50% 54% 46% / 60% 40% 58% 42%;
  animation:fog-drift-b calc(var(--fog-pace) * 1.7) ease-in-out infinite alternate;
}

.fog--high > i{
  --fog-op:.55;
  right:-22%;
  bottom:6%;
  width:84%;
  height:72%;
  border-radius:46% 54% 42% 58% / 52% 46% 54% 48%;
  animation:fog-drift-c calc(var(--fog-pace) * 1.25) ease-in-out infinite alternate;
}

/* Third wash in front, deliberately the thinnest of the three: it fills
   the middle where the other two cross, so the bank over the rock has the
   same internal churn as the one behind it. */
.fog--front::after{
  --fog-op:.42;
  left:12%;
  bottom:-26%;
  width:72%;
  height:104%;
  border-radius:52% 48% 58% 42% / 44% 58% 42% 56%;
  animation:fog-drift-a calc(var(--fog-pace) * .92) ease-in-out infinite alternate;
}

/* Each wash drifts, turns and thickens at once. The turn is what sells
   it — rotating a lopsided blob churns its edge, which the eye picks up
   long before it notices a slow sideways slide. */
@keyframes fog-drift-a{
  from{ transform:translate3d(-26%,4%,0) rotate(-9deg) scale(.88);
        opacity:calc(var(--fog-op) * .3); }
  to  { transform:translate3d(28%,-6%,0) rotate(11deg) scale(1.26);
        opacity:var(--fog-op); }
}

@keyframes fog-drift-b{
  from{ transform:translate3d(27%,-5%,0) rotate(8deg) scale(1.24);
        opacity:var(--fog-op); }
  to  { transform:translate3d(-30%,6%,0) rotate(-12deg) scale(.86);
        opacity:calc(var(--fog-op) * .26); }
}

@keyframes fog-drift-c{
  from{ transform:translate3d(-31%,6%,0) rotate(12deg) scale(.84);
        opacity:calc(var(--fog-op) * .24); }
  to  { transform:translate3d(25%,-7%,0) rotate(-10deg) scale(1.3);
        opacity:var(--fog-op); }
}

/* ---------- the cats ---------- */

/* One cat per guest who has said yes, dropped on the hill by
   shared/cats.js. Opt-in like the rest of the scene: the page supplies
   an empty container, the module fills it.

   Sizes come from the drawings rather than from this sheet. Each cat
   carries the pixel width and height of its own source artwork in
   --cw/--ch, and the ten share one --cat-unit, so they keep the sizes
   they were drawn at relative to each other: the curled-up one is meant
   to be lower than the sitting one, not squashed into a matching box.

   Where they stand depends on the shape of the window, and this is
   where that is decided. --cat-layout names which of the two the
   module should run — see "Where they stand" at the top of cats.js:

     hill   the default, and what the painting was framed for.
            Sixteen spots pinned by eye against it, one guest each,
            nobody moved by anybody else's reply.
     stack  for a window no wider than it is tall. Four or five to a
            line, clumped, stacking upward.

   --cx and --cb are plain numbers, turned into per cent down here, so
   the module can do its sums in px and hand over one figure each. */
.cats{
  z-index:4;
  --cat-layout:hill;

  /* One source pixel of artwork, as a length. The whole clowder scales
     from here — the only number to turn if the cats want to be bigger.

     Capped on the width as well as set on the height, and the cap is
     load-bearing: the spots in cats.js are spaced in per cent of the
     width, so a cat sized purely off the height grows sideways into
     its neighbour as the window narrows, and a narrow-ish desktop
     window would put two cats in the same patch of grass. Below about
     16:9 the width takes over and they shrink to keep their distance.
     At 16:9 and wider the two are the same number. */
  --cat-unit:min(.0546vh, .0307vw);
}

/* ---------- the stack ---------- */

/* Everything from here to the end of this block is the packed layout,
   and none of it is read unless --cat-layout says stack.

   A window no wider than it is tall has no room for a hillside: three
   cats at the size they are on a desktop already fill the frame. So
   they stop being a hillside and become a crowd — four or five to a
   line, packed close enough to overlap properly, and the rest stacked
   straight up behind them, line on line, as high as the guest list
   goes. A phone is where this invite actually gets opened, and this is
   what it gets. */
@media (max-aspect-ratio:1/1){
  .cats{
    --cat-layout:stack;

    /* How many to a line. The width decides it too, and whichever
       runs out first wins, so a narrow phone quietly does four. */
    --cat-row-max:5;

    /* Pitch is how much room each cat is given as a share of its own
       width: a fifth less than it takes means a fifth of it is behind
       the next one along, which is what a crowd looks like. Gap is the
       most air to leave between two of them when the line is not full
       — almost none, because huddling is the idea here. */
    --cat-pitch:.82;
    --cat-gap:.06;
    --cat-margin:2;

    /* Up the frame: the first line's height off the bottom, how much
       higher each line behind it sits, and how far it may climb before
       the stack squashes rather than carrying on into the fog. They
       barely shrink as they go — depth is not the idea here, standing
       behind somebody is, and a cat five lines up still wants to be a
       cat you can see. */
    --cat-b0:2;
    --cat-rise:8.5;
    --cat-ceiling:40;
    --cat-shrink:.975;

    /* The wobbles, because a row of evenly spaced cats stops reading
       as cats. Every other line leans half a cat sideways so the one
       behind stands in a gap; a name's own draw puts it up or down its
       line by --cat-jitter-b per cent of the frame, and takes
       --cat-jitter-s off its size for the whole of that climb, so up
       the slope is also further away. */
    --cat-stagger:.5;
    --cat-jitter-b:1.4;
    --cat-jitter-s:.05;
  }
}

.cat{
  position:absolute;
  left:calc(var(--cx) * 1%);
  bottom:calc(var(--cb) * 1%);

  /* Nearer is in front. The packer counts down from the back line, so
     the cats on the ground cover the ones up the hill rather than the
     other way about. */
  z-index:var(--cz,1);

  /* Anchored on the middle of its own feet, so --cs grows a cat up out
     of the ground instead of lifting it off. */
  --cu:calc(var(--cat-unit) * var(--cs));
  width:calc(var(--cw) * var(--cu));
  height:calc(var(--ch) * var(--cu));
  margin-left:calc(var(--cw) * var(--cu) / -2);

  display:block;
  padding:0;
  border:0;
  background:none;
  color:var(--ink);
  font:inherit;
  cursor:pointer;

  /* A cat is pressed, so the tap should land at once rather than wait
     to see whether it was half of a double-tap zoom — and without the
     grey box iOS paints over whatever you pressed, which here would be
     a rectangle around a cut-out cat. */
  touch-action:manipulation;
  -webkit-tap-highlight-color:transparent;

  /* The layer is inert so the page carries on working through it. The
     cats are the one thing on it that is not. */
  pointer-events:auto;

  /* They arrive one at a time rather than all at once — cats.js hands
     out the delay. */
  animation:cat-arrive .9s var(--ease) both;
  animation-delay:var(--in-delay,0ms);

  /* The line is packed, so a cancellation closes the gap and everyone
     after it shuffles along — and a window that changes size repacks
     outright. Walking there, and growing into the new size on the way
     if it changed lines, is both nicer to watch and more honest than
     blinking out and in somewhere else. */
  transition:left .9s var(--ease), bottom .9s var(--ease),
             width .9s var(--ease), height .9s var(--ease),
             margin-left .9s var(--ease),
             /* The hop below, which is a different kind of movement
                and wants its own clock: walking is slow because it is
                scenery, a hop is an answer and has to land at once.
                The curve overshoots at both ends, so it goes up past
                where it settles and dips a hair below the ground on
                the way back — the difference between a lift and a
                bounce, and the whole of why it reads as feedback. */
             translate .26s cubic-bezier(.34,1.56,.64,1);

}

/* Pointed at, tabbed to, or tapped: the cat lifts, and drops back the
   moment it stops being any of those.

   It is the only answer the hill gives that is not the name, and on a
   phone it is most of the point — a tap that produces a name a
   hundred pixels above your thumb is hard to connect to the thing you
   pressed, where a cat that moves under it is not. These are
   cut-outs with no box around them and nothing else to press, so
   something has to say "this one, and it is a button".

   A per cent, because on `translate` that means a per cent of the
   element's own height — so the big cat in front hops about four
   pixels and the small one up the back barely one, and neither needs
   to be told its own size. A flat 5px would throw a distant cat half
   its own body into the air.

   `translate` rather than a transform, because cat-arrive owns the
   transform and fills it forwards: a hop written there would be
   overruled by an animation that finished a second ago. The two
   compose, so a cat can still be arriving while you point at it. */
.cat:hover,
.cat:focus,
.cat--petted{ translate:0 -2.5%; }

.cat img{
  display:block;
  width:100%;
  height:100%;
  /* Faces in, out on the wings. */
  transform:scaleX(var(--cf,1));

  /* Further away is smaller and thinner, off the one number. Air, for
     free: a line only has to say how big its cats are. */
  opacity:min(1, .58 + var(--cs) * .42);
}

/* Whoever is being pointed at comes forward, so the name is never read
   through the cat standing in front. Clear of every line's own
   z-index, however many lines deep the hill goes. */
.cat:hover,
.cat:focus,
.cat--petted{ z-index:99; }

.cat:focus-visible{ outline:2px solid var(--accent); outline-offset:4px; }

@keyframes cat-arrive{
  from{ opacity:0; transform:translateY(.7rem) scale(.92); }
  to  { opacity:1; transform:none; }
}

/* Replaces cat-arrive outright rather than transitioning against it —
   a shorthand beats a shorthand, and there is nothing to unwind. */
.cat--leaving{
  animation:cat-leave .55s var(--ease) both;
  pointer-events:none;
}

@keyframes cat-leave{
  from{ opacity:1; transform:none; }
  to  { opacity:0; transform:translateY(.5rem) scale(.9); }
}

/* The guest's name, over the cat's head. It is also the button's own
   text, so a screen reader reads the name and a keyboard reaches it by
   tabbing — the hover is the mouse's way in, not the only one.

   .cat--petted is the third way, and the one a phone has: cats.js puts
   it on whichever cat was last tapped, because neither :hover nor
   :focus can be relied on there. See "petting" in that file. */
.cat-name{
  position:absolute;
  left:50%;
  bottom:calc(100% + .35rem);
  translate:-50% .3rem;

  padding:.3em .62em;
  background:rgb(var(--scrim-rgb) / .85);
  color:var(--ink);
  font-size:.7rem;
  letter-spacing:.14em;
  text-transform:uppercase;
  white-space:nowrap;

  opacity:0;
  transition:opacity .25s var(--ease), translate .25s var(--ease);
  /* The label must never eat the hover from the cat under it. */
  pointer-events:none;
}

.cat:hover .cat-name,
.cat:focus .cat-name,
.cat--petted .cat-name{ opacity:1; translate:-50% 0; }

/* Out by an edge the name hangs inward instead of centred, or the
   viewport would cut it in half. cats.js tags which. */
.cat--l .cat-name{ left:0; translate:0 .3rem; }
.cat--l:hover .cat-name,
.cat--l:focus .cat-name,
.cat--l.cat--petted .cat-name{ translate:0 0; }

.cat--r .cat-name{ left:auto; right:0; translate:0 .3rem; }
.cat--r:hover .cat-name,
.cat--r:focus .cat-name,
.cat--r.cat--petted .cat-name{ translate:0 0; }

/* ---------- the shooting star ---------- */

/* The one star that does not hold still. It crosses the sky, writes the
   line about the cats in its wake, and a while later comes back through
   and takes the words with it — so nothing on this layer ever simply
   fades: everything arrives and leaves by the same gesture, in the same
   direction, drawn and undrawn by the same star.

   Three masks, no opacity fades anywhere, each one a single moving edge
   in a gradient:

     --wipe   the written edge of the words, chasing the first flight
     --erase  the edge that takes them away, chasing the second
     --tail   the back of the streak itself

   --tail is the star's own version of the other two. It starts past the
   head, so the dash ignites as a point and draws its trail out behind
   it, and it finishes by travelling forward again, so the trail is
   swept into the head and the head goes out last. That is what a meteor
   actually does, and it is the same move the words make.

   The streak flies at exactly the speed of --wipe, and on the way back
   at exactly the speed of --erase. That agreement is the whole effect:
   it is what makes the star look like it is writing and then rubbing
   out, rather than passing over something that was going to change
   anyway.

   Everything here is one line of type and one 1.5px dash. No glow, for
   the same reason the stars have none.

   Two of them, left and right, half a cycle out of phase — see
   .wish-line--l / --r. A hint that turns up in the same corner every
   time is one a guest has stopped seeing by the second pass, and the
   whole point of this layer is that somebody notices the cats, so it
   moves.

   Gated on there being a cat to hover: an empty hill under a label
   telling you to hover it is worse than no label, so the layer stays
   out of the sky until cats.js has stood at least one cat up. Which
   also means no supabase-config.js, no JavaScript and a failed call all
   leave the night exactly as it was, like the rest of the scene.

   Percentages in the keyframes are of --wish-cycle, and the two flights
   are the same length: the first runs to 6.5% of it, the words hold to
   40%, the second runs to 46.5% rubbing them out as it goes, and the
   rest of the cycle is the other side's turn. Registered as percentages
   for the same reason the star opacities are registered as numbers — an
   unregistered custom property reaches a keyframe as a token, and
   calc() on a token is not dependable. */
@property --wipe  { syntax:'<percentage>'; inherits:false; initial-value:130%; }
@property --erase { syntax:'<percentage>'; inherits:false; initial-value:-16%; }
@property --tail  { syntax:'<percentage>'; inherits:false; initial-value:-25%; }

.wish{
  z-index:5;
  --wish-cycle:20s;
  display:none;
}

/* The hill has somebody on it. */
body:has(.cat) .wish{ display:block; }

/* Hugs its own text rather than taking a width, because the streak's
   travel and the mask that follows it are both measured in percentages
   of this box — a box wider than the words would put the star well
   ahead of what it is supposed to be writing. Narrow enough to break
   into two short lines at any size, which is also what makes it read as
   a note pinned to the sky rather than a banner across it.

   High in the sky on purpose: that band is clear of the invitation at
   every size the page is actually read at, and the stars' own mask
   fades them out long before it. */
.wish-line{
  position:absolute;
  margin:0;
  width:max-content;
  max-width:min(15rem,52vw);
  --at:0s;
}

/* The two spots. Different corner, different height, and half a cycle
   apart, so what a guest sees is one note at a time turning up
   somewhere new rather than two copies of the same sentence at once.
   That they would overlap on a phone does not matter: they are never
   both on screen. */
.wish-line--l{
  top:clamp(1.25rem,6vh,4.5rem);
  left:clamp(1.25rem,6vw,5rem);
  text-align:left;
}

.wish-line--r{
  top:clamp(5rem,16vh,10rem);
  right:clamp(1.25rem,6vw,5rem);
  text-align:right;
  /* Negative, so it is already half a cycle in when the page opens
     instead of leaving the sky empty for ten seconds first. */
  --at:calc(var(--wish-cycle) / -2);
}

.wish-words{
  display:block;
  /* Set like .billing and .cat-name — the page has one voice for a
     small aside and this is one. */
  font-size:.7rem;
  line-height:1.5;
  letter-spacing:.14em;
  text-transform:uppercase;
  /* Balanced so the break is a decision rather than whatever the
     measure happens to do with the last word. */
  text-wrap:balance;
  color:var(--ink);
  opacity:.82;
  /* Same halo the invitation gets, for the same reason. */
  text-shadow:0 1px 12px rgb(var(--scrim-rgb) / .7),
              0 0 3px rgb(var(--scrim-rgb) / .45);

  /* Written to --wipe, taken away by --erase. The resting values are
     the finished line, so with the animation off the words are simply
     there — see the reduced-motion block at the foot of this file. */
  --wipe:130%;
  --erase:-16%;
  -webkit-mask-image:linear-gradient(to right,
    transparent 0 var(--erase),
    #000 calc(var(--erase) + 14%) var(--wipe),
    transparent calc(var(--wipe) + 14%));
  mask-image:linear-gradient(to right,
    transparent 0 var(--erase),
    #000 calc(var(--erase) + 14%) var(--wipe),
    transparent calc(var(--wipe) + 14%));

  animation:wish-write var(--wish-cycle) linear var(--at) infinite;
}

/* Both edges start and finish outside the box, so the jump back round
   the loop happens while there is nothing on screen to see it. */
@keyframes wish-write{
  0%    { --wipe:-16%; --erase:-16%; }
  6.5%  { --wipe:130%; --erase:-16%; }
  40%   { --wipe:130%; --erase:-16%; }
  46.5% { --wipe:130%; --erase:130%; }
  100%  { --wipe:130%; --erase:130%; }
}

/* The track, not the star: full width of the line, with the drawing
   hung off its left edge so a translate of 0% to 100% carries the head
   from the first letter to the last. Moving the track is what lets the
   travel be measured in the line's width instead of the dash's. */
.wish-streak{
  position:absolute;
  left:0;
  bottom:calc(100% + .3rem);
  width:100%;
  height:0;
  animation:wish-fly var(--wish-cycle) linear var(--at) infinite;
}

/* The dash. Tapered from nothing at the tail to solid at the head, the
   way a streak actually goes out behind one, and tilted off its own
   head so the tail lifts — the flight drops about the same angle, so
   the thing falls along its own length rather than sliding sideways.

   The mask is the whole of how it comes and goes: --tail is the back of
   it, and the dash is only ever the strip between there and the head.
   No opacity on this element or on the track, so there is never a
   second opinion about when the star is visible. */
.wish-streak i{
  position:absolute;
  right:100%;
  bottom:0;
  width:clamp(2.75rem,9vw,4.5rem);
  height:1.5px;
  background:linear-gradient(to right,
    rgba(243,236,224,0),
    rgba(243,236,224,.5) 62%,
    #f3ece0);
  border-radius:1px;
  rotate:3deg;
  transform-origin:100% 50%;

  --tail:-25%;
  -webkit-mask-image:linear-gradient(to right,
    transparent 0 var(--tail), #000 calc(var(--tail) + 22%));
  mask-image:linear-gradient(to right,
    transparent 0 var(--tail), #000 calc(var(--tail) + 22%));

  animation:wish-burn var(--wish-cycle) linear var(--at) infinite;
}

/* The head, lopsided like the hand-flicked dots in .stars. */
.wish-streak i::after{
  content:"";
  position:absolute;
  right:-1px;
  top:50%;
  width:3px;
  height:3px;
  margin-top:-1.5px;
  background:#f3ece0;
  border-radius:58% 42% 52% 48% / 46% 56% 44% 54%;
}

/* Out and back. The snap home at 7% is a tenth of a second while the
   dash is fully masked — invisible, and cheaper to be sure of than a
   slow drift backwards across the sky. */
@keyframes wish-fly{
  0%    { translate:-6% -.6rem; }
  6.5%  { translate:106% .5rem; }
  7%    { translate:-6% -.6rem; }
  40%   { translate:-6% -.6rem; }
  46.5% { translate:106% .5rem; }
  100%  { translate:106% .5rem; }
}

/* Ignite, draw the trail out behind the head, hold it, then sweep it
   forward into the head until there is nothing left — twice, once per
   flight. Anything at 100% or past it leaves no visible strip at all,
   which is what the long gaps are. */
@keyframes wish-burn{
  0%    { --tail:100%; }
  1.2%  { --tail:-25%; }
  5.3%  { --tail:-25%; }
  6.5%  { --tail:130%; }
  40%   { --tail:100%; }
  41.2% { --tail:-25%; }
  45.3% { --tail:-25%; }
  46.5% { --tail:130%; }
  100%  { --tail:130%; }
}

/* Fog this heavy puts pale wash directly under cream type. A soft dark
   halo costs nothing and keeps the invitation readable at any density —
   it is the reason --fog-mix can be turned up this far at all. */
body:has(.scene) .invite{
  text-shadow:0 1px 12px rgb(var(--scrim-rgb) / .7),
              0 0 3px rgb(var(--scrim-rgb) / .45);
}


/* ---------- the scene on a phone ---------- */

/* `cover` on a portrait screen crops hard from the sides, so the framing
   is a choice about what survives. Centred keeps the crest of the hill in
   shot, which is the only subject the painting has left. */
@media (max-aspect-ratio:3/4){
  :root{
    /* The hill crests lower in the frame here, so the fog sits lower and
       the stars get the extra sky they have earned. */
    --fog-h:50svh;
    --fog-blur:34px;
  }
  /* Bigger marks here, not the same ones: a 2px dot that reads fine on
     a 27" sky disappears on a 6" one, and a phone is where this invite
     actually gets opened. */
  .stars{ --star-fade:56%; --star-gone:80%; --star-scale:1.75; }

  /* The stack is already running by here — it starts at 1:1, well
     above this — so all a phone adds is its own size. Smaller than the
     cap a wider window lands on, because a crowd five deep on a 6"
     screen has to leave some night around it. */
  .cats{ --cat-unit:.042vh; }
}

/* Three animated blurs is a lot of compositing for a phone GPU, and
   the third is the one nobody could point to afterwards. */
@media (max-width:30rem){
  .fog--back > i,
  .fog--high > i{ display:none; }
}

/* A landscape phone has no spare sky — the invitation fills the frame
   top to bottom, and the only place left for a line of type in the air
   is across the headline. It sits this one out: the cats still answer to
   a hover, and this was never the only way to find that out. */
@media (max-height:30rem){
  body:has(.cat) .wish{ display:none; }
}

@media (prefers-reduced-motion:reduce){
  *{ transition-duration:.01ms !important; animation-duration:.01ms !important; }

  /* Crushing the duration leaves an animation parked at whatever frame
     it happened to be on, which for a sky of breathing stars means half
     of them stuck mid-dip and a visibly dimmer night. Drop the
     animation outright instead, so every star sits at the opacity it
     was placed at. */
  .star{ animation:none !important; }

  /* The duration is crushed above but the delay is not, and a staggered
     entrance with no entrance left is just a cat that takes a second to
     turn up. They all arrive at once instead. */
  .cat{ animation-delay:0ms !important; }

  /* The words are the point and the flight was only the delivery, so
     keep the one and drop the other: with no animation the mask rests
     open and the line simply sits in the sky, which is the whole of
     what it had to do. The streak goes rather than parking mid-air as a
     dash with no explanation. */
  .wish-words{ animation:none !important; }
  .wish-streak{ display:none; }

  /* The two take turns in time, and with no time left they would both
     simply be there — the same sentence written twice in the sky. One
     of them is the note; the other was only the second chance to catch
     an eye, and nothing is catching an eye here anyway. */
  .wish-line--r{ display:none; }
}
