/* hanny-and-andra.css — base styles: reset, tokens, sections, nav dots, form,
   gallery. Colour comes from palettes.css via the data-palette tokens. */

/* Type. Both families are served from this repo, not from Google. The page used to
   pull them from fonts.googleapis.com at runtime; when that request does not land the
   display script drops to the next family in --font-display — Comic Sans MS, via the
   generic `cursive` on Windows — and a guest's first impression is the wrong one.
   These are Google's own latin and latin-ext woff2 files, committed under
   /static/fonts/, so the page makes no third-party font request at all.

   One file per family, style and subset: Cormorant Garamond ships as a variable font,
   so a single file covers every weight the page uses. unicode-range keeps latin-ext
   optional — a browser only downloads it if a character in that range is drawn. */
@font-face {
  font-family: "Pinyon Script";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/static/fonts/pinyon-script-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "Pinyon Script";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/static/fonts/pinyon-script-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Cormorant Garamond, upright. Google serves one variable file for 400, 500 and 600,
   which are the weights this page uses (400 body, 500 body, 600 labels). */
@font-face {
  font-family: "Cormorant Garamond";
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url("/static/fonts/cormorant-garamond-roman-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "Cormorant Garamond";
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url("/static/fonts/cormorant-garamond-roman-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Cormorant Garamond, italic — the Ar-Rum verse — 400 to 600 from the variable file. */
@font-face {
  font-family: "Cormorant Garamond";
  font-style: italic;
  font-weight: 400 600;
  font-display: swap;
  src: url("/static/fonts/cormorant-garamond-italic-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "Cormorant Garamond";
  font-style: italic;
  font-weight: 400 600;
  font-display: swap;
  src: url("/static/fonts/cormorant-garamond-italic-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* Minimal preflight. This replaces the reset the Tailwind browser build was
   supplying implicitly. Every rule here is one the page was quietly relying on:
   without them, UA defaults come back and the layout shifts — the root
   line-height, element margins/paddings, the fieldset/legend box, heading
   weight, and link colour and underline. Limited to what can match this markup. */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  border: 0 solid;
}

html {
  /* The page's baseline line-height used to come from Tailwind's html rule. */
  line-height: 1.5;
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-size: inherit;
  font-weight: inherit;
}

a {
  color: inherit;
  text-decoration: inherit;
}

ol,
ul,
menu {
  list-style: none;
}

img,
svg,
video,
canvas {
  display: block;
  vertical-align: middle;
}

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

button,
input,
select,
optgroup,
textarea {
  font: inherit;
  color: inherit;
  background-color: transparent;
  border-radius: 0;
  opacity: 1;
}

/* The document is the scroller, so scroll-linked libraries (AOS) see real scroll. No
   `scroll-behavior: smooth` here: the dots ask for a smooth scroll themselves, and on the root
   the property also governs how the snap engine animates a gesture's landing — under it a swipe
   arrives in two or three jumps instead of the fling's own deceleration (todo 072 measured it). */
html {
  scroll-snap-type: y mandatory;
  overflow-x: hidden;
  /* Nothing scrolls sideways, so horizontal overscroll buys nothing — and containing it
     is what some Chrome versions check before handing a drag near the left edge to the
     back gesture. */
  overscroll-behavior-x: contain;
}

:root {
  --bg: #0f1418;
  --ink: #f5efe6;
  --muted: #c3b8a6;
  /* The third text tone: brighter than --muted and a step under --accent. The cover's
     guest line and the countdown's city use it. palettes.css sets one per palette; this
     is the fallback for a page rendered without a palette. */
  --ink-soft: #d6b97a;
  --accent: #c9a24b;
  --panel: rgba(15, 20, 24, 0.55);
  --keyline: rgba(201, 162, 75, 0.45);
  --radius: 18px;
  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 2rem;
  --space-4: 4rem;
  --slide-pad: clamp(1.25rem, 4vw, 4rem);
  /* A script stack in this order on purpose: the generic `cursive` is Comic Sans MS on
     Windows, so it is the last resort rather than the first fallback. */
  --font-display: "Pinyon Script", "Segoe Script", "Brush Script MT", "Snell Roundhand", "Apple Chancery", cursive;
  --font-body: "Cormorant Garamond", Georgia, "Times New Roman", serif;
}

body {
  font-family: var(--font-body);
  font-size: clamp(1.05rem, 0.5vw + 1rem, 1.2rem);
  font-weight: 500;
  color: var(--ink);
  background: var(--bg);
}

.deck {
  position: relative;
  z-index: 1;
}

.slide {
  position: relative;
  display: flex;
  align-items: center;
  /* Centring that gives way to the top edge when the item does not fit: a plain `center`
     puts the overflow above the scroll start, where no scroll can reach it. Declared after
     the plain value, so a browser without `safe` keeps the old alignment. */
  align-items: safe center;
  justify-content: center;
  /* dvh is the height the browser is showing right now. svh (the old value here) is the
     height with a phone's toolbar showing, so as soon as the toolbar hid, the screen grew
     past the slide and a sliver of the next section came in; vh is the large viewport,
     taller than the screen. The three are declared in order: the last one a browser
     understands wins. */
  min-height: 100vh; /* fallback: no svh support */
  min-height: 100svh;
  min-height: 100dvh;
  /* ... and never taller than one screen: content that does not fit scrolls inside the slide
     rather than pushing the next section past the bottom. On a browser without dvh this line
     is dropped and the slide grows as it did before. */
  max-height: 100vh;
  max-height: 100dvh;
  /* Hidden, not auto: a slide only takes the gesture when initSlideScrollers() marks it
     .is-scrollable, because offering every swipe to a container with nothing to scroll is
     what made the page feel stuck. */
  overflow: hidden;
  /* Seamless: the slides that do scroll draw no bar. */
  scrollbar-width: none;
  -ms-overflow-style: none;
  padding: var(--slide-pad);
  background: var(--bg);
  /* Where a gesture settles. Mandatory snap (`html`) pulls the scroll onto the nearest snap
     position once the motion ends, and Chrome picks the one in the direction of the fling, so a
     swipe lands on the next section. Nothing here forces that stop: `scroll-snap-stop: always`
     used to sit on this line, and it made the browser drop the fling's momentum at `touchend`
     and animate straight to the next section in two or three frames — a teleport the finger
     never asked for (todo 072 measured both). */
  scroll-snap-align: start;
}

/* The one rule that gives a slide a scroller: set by initSlideScrollers() when the content
   is taller than the box, so a slide that fits never sees the gesture. The bar stays hidden
   either way. */
.slide.is-scrollable {
  overflow-y: auto;
}

/* WebKit draws its own bar; hide it too, so the scroller stays seamless. */
.slide::-webkit-scrollbar {
  width: 0;
  height: 0;
}

/* Opaque by default so only the sections named below let the fixed scene-1
   backdrop (.hero__bg) show through. */
.slide--hero,
.slide--pria,
.slide--wanita,
.slide--gift,
.slide--countdown,
.slide--thanks {
  background: transparent;
}

.slide__inner {
  width: 100%;
  max-width: 900px;
  text-align: center;
}

.kicker {
  margin: 0 0 var(--space-2);
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.35em;
  text-transform: uppercase;
}

/* Section nav */
.dots {
  position: fixed;
  top: 50%;
  right: clamp(0.75rem, 2vw, 1.5rem);
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: 0;
  transform: translateY(-50%);
  /* The pager is chrome over the content, not content. Half opacity keeps it
     usable without pulling the eye away from the section being read. */
  opacity: 0.5;
}

.dots button {
  width: 12px;
  height: 44px;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: pointer;
}

.dots button::before {
  content: "";
  display: block;
  width: 10px;
  height: 10px;
  margin: 0 auto;
  border: 1px solid var(--muted);
  border-radius: 50%;
  transition: transform 0.3s ease, background 0.3s ease, border-color 0.3s ease;
}

.dots button.is-active::before {
  background: var(--accent);
  border-color: var(--accent);
  transform: scale(1.4);
}

/* Hero */

.hero {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: space-between;
  gap: var(--space-2);
  /* Inset from the slide edges: lifts the bottom line up and keeps the title
     clear of the top and side edges. */
  padding: var(--space-2) var(--space-2) var(--space-3);
  /* dvh, not vh: a phone's 100vh is taller than its screen. See .slide. */
  min-height: calc(100vh - 2 * var(--slide-pad));
  min-height: calc(100svh - 2 * var(--slide-pad));
  min-height: calc(100dvh - 2 * var(--slide-pad));
}

.hero__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 400;
  line-height: 1.15;
  text-align: left;
  /* Base the two lines scale from: "Wedding" x1.25, "Invitation" x2.
     "Invitation" is 3.35em wide, so 2x is 6.7em: 9.6vw + a 1.8rem floor keeps
     it inside the slide padding even on a ~280px-wide screen. These values are
     exactly 0.8x of 2.25rem / 12vw / 6rem. */
  --title-size: clamp(1.8rem, 9.6vw, 4.8rem);
}

.hero__title-sm {
  font-size: calc(var(--title-size) * 1.25);
}

.hero__title-lg {
  font-size: calc(var(--title-size) * 2);
}

/* The cover's top-right corner holds two lines: the couple's names, and under them the
   guest the link was addressed to. One column, so a long guest name can never land on the
   names' line. */
.hero__top {
  position: absolute;
  top: calc(var(--slide-pad) + var(--space-2));
  right: calc(var(--slide-pad) + var(--space-2));
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.2rem;
  max-width: min(70%, 22rem);
  text-align: right;
}

.hero__sign {
  margin: 0;
  color: var(--ink);
  /* A step up from the 0.95rem the couple's names were set in: the corner is a signature,
     and it reads across a phone on a photo. The corner's max-width is unchanged, so the
     line still holds one line at 320px. */
  font-size: 1.2rem;
  letter-spacing: 0.18em;
}

/* The addressee from the link: the guest's own name, so the soft-ink tone — brighter than
   the muted body colour, a step under the accent the couple's names above it carry — set
   large enough to read at arm's length. It wraps inside the corner rather than pushing the
   block wider. */
.hero__guest {
  margin: 0;
  color: var(--ink-soft);
  font-size: 1.05rem;
  line-height: 1.5;
  letter-spacing: 0.04em;
  overflow-wrap: anywhere;
}

.hero__foot {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-1);
}

/* One row per day, spanning the hero: the date hard against the left edge, the city
   against the right. Normal case and lighter tracking keep the pair a detail rather
   than a headline. */
.hero__date {
  display: flex;
  flex-wrap: wrap;
  margin: 0;
  color: var(--muted);
  font-size: 0.85rem;
  letter-spacing: 0.08em;
}

/* margin-left: auto holds the city against the right edge even when it wraps onto its
   own row; the padding is the gap between the two halves. */
.hero__date-city {
  margin-left: auto;
  padding-left: var(--space-1);
}

.hero__invite {
  margin: 0;
  color: var(--muted);
  font-size: calc(1.05rem * 0.75);
  line-height: 1.7;
  text-align: justify;
  text-justify: inter-word;
}

/* Hero background slideshow. No overflow rule of its own: .slide already hides it, and that
   is what lets initSlideScrollers() give the cover a scroller if its content ever needs one
   (an id rule here would out-rank .slide.is-scrollable). */
#hero .slide__inner {
  position: relative;
  z-index: 1;
}

/* Hero background slideshow — one fixed layer pinned behind the whole page, so
   the hero, both mempelai slides and the thank-you slide share the same photo.
   #hero is position:relative with z-index:auto, so it creates no stacking
   context and this fixed layer joins .deck's (z-index 1) instead. */
.hero__bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
}

.hero__bg-img {
  position: absolute;
  inset: 0;
  opacity: 0;
  background-position: center 30%;
  background-repeat: no-repeat;
  background-size: cover;
  /* 0.84s is 70% of the original 1.2s: the crossfade scales with the cycle. */
  transition: opacity 0.84s ease;
}

.hero__bg-img.is-active {
  opacity: 1;
}

/* Film artifacts CamanJS cannot do: light leaks, grain, a vignette and edge
   aberration. All overlays — no per-pixel pass, no JPEG re-encode. */
.film-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.film {
  position: absolute;
  inset: 0;
  /* Above the ::after scrim (which is positioned with z-index auto), because
     grain belongs over a darkened frame — under it the dark areas eat it. Still
     inside .hero__bg, which is z-index 0, so all page text stays above and crisp. */
  z-index: 2;
  pointer-events: none;
}

/* Light entering the camera: screen-blended so it blooms instead of washing out. */
.film__leak {
  position: absolute;
  inset: 0;
  background-image:
    radial-gradient(58% 40% at 100% 2%, rgba(255, 178, 98, 0.46), rgba(255, 150, 80, 0) 62%),
    radial-gradient(52% 38% at 0% 100%, rgba(255, 214, 146, 0.3), rgba(255, 190, 120, 0) 66%);
  mix-blend-mode: screen;
}

/* A tiled feTurbulence tile: cheaper and more even than a per-pixel pass, and it
   never has to survive a JPEG re-encode. stitchTiles hides the tile seam. */
.film__grain {
  position: absolute;
  inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-repeat: repeat;
  mix-blend-mode: overlay;
  opacity: 0.075;
}

/* Matte finish: clear across the centre, darkening only the extreme corners. */
.film__matte {
  position: absolute;
  inset: 0;
  background-image: radial-gradient(
    122% 92% at 50% 45%,
    rgba(0, 0, 0, 0) 54%,
    rgba(26, 15, 8, 0.42) 100%
  );
}

/* Edge aberration, on the ACTIVE slide only, so one layer is filtered rather than
   nine. This is the one item here with a real performance question on a phone: to
   drop it, delete this single rule. */
.hero__bg-img.is-active {
  filter: url("#film-ca");
}

/* Ar-Rum */

.panel {
  max-width: 720px;
  margin: 0 auto;
  padding: var(--space-3);
  /* No keyline: the verse sits on the tint alone. */
  border-radius: var(--radius);
  background: var(--panel);
  backdrop-filter: blur(6px);
}

.ayat__text {
  margin: 0 0 var(--space-2);
  font-size: clamp(1rem, 2.4vw, 1.4rem);
  font-style: italic;
  line-height: 1.7;
}

.ayat__source {
  margin: 0;
  color: var(--accent);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
}

/* Mempelai */


/* The backdrop behind these four slides is blurred and darkened, so the type and the cards —
   not the photo — are what read. A later slide paints after the earlier one that holds
   .hero__bg, so this overlay lands on top of the photo; .slide__inner is lifted to z-index 1 so
   the overlay stays under the kicker and the content. Same value on all four on purpose: the
   sections should look like one another, and the trade is the same on each — the small muted
   lines over a bright frame are the ones that give up contrast first, and the veil's alpha is
   the knob if a photo makes that read wrong. */
.slide--pria::before,
.slide--wanita::before,
.slide--gift::before,
.slide--countdown::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(6, 8, 11, 0.5);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
}

.slide--pria .slide__inner,
.slide--wanita .slide__inner,
.slide--gift .slide__inner,
.slide--countdown .slide__inner {
  position: relative;
  z-index: 1;
}

/* The card is the photo: the image fills it, the names sit on its darker bottom edge,
   and the nickname runs down its right side. */
.mempelai {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-1);
  width: min(100%, 420px);
  /* 3:4 keeps the card inside one phone screen; the sources are narrower still
     (900x1600 and 1066x1600), so cover fills it and crops a little of the height. */
  aspect-ratio: 3 / 4;
  max-height: 70svh;
  margin: 0 auto;
  padding: var(--space-3);
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  background: var(--bg);
  overflow: hidden;
  /* Lifted off the blurred backdrop. */
  box-shadow: 0 1.1rem 2.4rem rgba(0, 0, 0, 0.5);
  transform-style: preserve-3d;
}

/* The card's own colour still sits under the photo, so a photo that has not landed yet
   shows the slide colour rather than the backdrop. */
.slide--pria .mempelai,
.slide--wanita .mempelai {
  background: var(--bg);
}

/* The photo is the card's surface, not a thumbnail inside it. */
.mempelai__photo {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.mempelai__photo img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* A vignette, not a veil: the middle of the frame stays as the photo was and only the
   border and the corners darken. Todo 059 veiled the whole photo so the vertical nickname
   had something behind it, which dimmed the couple with it; a `circle` is sized from the
   centre to the farthest corner, so the corner the nickname runs down is still the darkest
   stop while the centre is untouched, and the nickname's own text shadow carries it. The
   overlay is a positioned ::after inside the photo wrapper, so it paints over the image
   without touching the pixels the grade works on, and the names' band (.mempelai::after)
   still sits above it. */
.mempelai__photo::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: radial-gradient(
    circle at 50% 50%,
    rgba(4, 6, 9, 0) 40%,
    rgba(4, 6, 9, 0.34) 72%,
    rgba(4, 6, 9, 0.82) 100%
  );
  pointer-events: none;
}

/* The names' own band: the lower part of the card is darkened so the type never has to
   fight the photo. ::after is painted after the photo, so the text is lifted above it.
   It stops at 42% of the card and is 0 well before its top edge: it covers the type block
   (name, nickname, lineage) and leaves the middle of the frame to the vignette above. */
.mempelai::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 42%;
  background: linear-gradient(
    to top,
    rgba(4, 6, 9, 0.92) 8%,
    rgba(4, 6, 9, 0.58) 48%,
    rgba(4, 6, 9, 0) 100%
  );
  pointer-events: none;
}

.mempelai__name,
.mempelai__profile,
.mempelai__parents {
  position: relative;
  z-index: 1;
  /* The band above the type is shorter and lighter than it was, so the type carries its
     own shadow: a bright frame cannot wash the names out, the same way the nickname in
     the corner has one. */
  text-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.6);
}

.mempelai__name {
  margin: 0;
  font-family: var(--font-display);
  /* Exactly 70% of the original clamp(1.75rem, 4.5vw, 2.5rem): the full name with
     its ", S.T." overran the card. Only this face moves. */
  font-size: clamp(1.225rem, 3.15vw, 1.75rem);
  font-weight: 400;
  line-height: 1.25;
}

/* Down the card's right edge, in the top corner: a label, not part of the name block, so
   the names own the bottom band. vertical-rl turns it 90° without a transform, which
   keeps it inside the card. */
.mempelai__nick {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  z-index: 2;
  margin: 0;
  color: var(--accent);
  font-size: 0.8rem;
  letter-spacing: 0.25em;
  text-transform: uppercase;
  writing-mode: vertical-rl;
  text-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.75);
}

.mempelai__parents {
  margin: 0;
  color: var(--muted);
  font-size: 0.9rem;
}

/* The lineage runs as two lines: the order on its own, then the parents' names under it.
   A guest reading the card wants the names, so they are the brighter of the two and the
   order stays a muted lead-in. Both spans are blocks, so no <br> is added to the markup. */
.mempelai__parents-order,
.mempelai__parents-names {
  display: block;
}

.mempelai__parents-names {
  margin-top: 0.15rem;
  color: var(--ink);
}

/* Tanggal Acara */

.section-title {
  margin: 0 0 var(--space-3);
  padding-bottom: 0.08em;
  color: var(--ink);
  font-family: var(--font-display);
  font-size: clamp(2rem, 6vw, 3.25rem);
  font-weight: 400;
  line-height: 1.25;
}

.events {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: var(--space-3);
}

.event {
  padding: var(--space-3);
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  background: var(--panel);
  text-align: left;
}

.event__city {
  margin: 0 0 var(--space-1);
  color: var(--accent);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.3em;
  text-transform: uppercase;
}

.event__title {
  margin: 0 0 var(--space-2);
  font-size: 1.15rem;
}

.event__line {
  margin: 0 0 0.25rem;
  font-size: 0.95rem;
  overflow-wrap: anywhere;
}

.event__muted {
  color: var(--muted);
}

/* The venue link: the Google Maps pin and then the words, on one row. inline-flex keeps the
   pin and the words on the same baseline, and the keyline under them runs the full width of
   the row, which is also the tap target. */
.event__map {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin-top: var(--space-2);
  color: var(--accent);
  font-size: 0.9rem;
  text-decoration: none;
  border-bottom: 1px solid var(--keyline);
}

.event__map-icon {
  flex: none;
  /* The mark's own 92.3x132.3 ratio, small enough that the row stays a detail of the card. */
  width: 0.8rem;
  height: 1.15rem;
}

/* A day with parts is a list, and reads as one: the preflight strips list markers from
   every ul, so the akad and the resepsi used to look like two more lines of the card. The
   marker is drawn rather than left to the browser's disc, so its size and colour are the
   page's. Only a day with `sessions` has it — a single-`time` day keeps a plain line. */
.event__sessions {
  display: grid;
  gap: 0.15rem;
  margin: 0 0 0.25rem;
}

.event__sessions .event__line {
  position: relative;
  margin: 0;
  padding-left: 1.05rem;
}

.event__sessions .event__line::before {
  content: "";
  position: absolute;
  top: 0.5em;
  left: 0.1rem;
  width: 0.32rem;
  height: 0.32rem;
  border-radius: 50%;
  background: var(--accent);
}

/* Countdown — the nearest day, and the four numbers to it. */

.countdown__meta {
  margin: 0 0 var(--space-3);
  color: var(--muted);
  font-size: 0.95rem;
}

/* The city is the part of the line a guest scans for, so it is set a step larger than the
   date beside it and in the soft-ink tone: a town name has to read at a glance, the date
   can be read carefully. The script rebuilds this span on a day's rollover. */
.countdown__city {
  color: var(--ink-soft);
  font-size: 1.2em;
  font-weight: 600;
}

/* Four tiles, and never wider than the phone: minmax(0, 1fr) lets a tile shrink below
   its content instead of pushing the row past the edge. */
.countdown {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--space-2);
  max-width: 640px;
  margin: 0 auto;
}

.countdown__tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  padding: var(--space-2) 0.4rem;
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  background: var(--panel);
}

/* Tabular figures keep a tile from twitching as the seconds turn over. */
.countdown__value {
  color: var(--ink);
  font-size: clamp(1.35rem, 6vw, 2.1rem);
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}

.countdown__label {
  color: var(--accent);
  font-size: 0.65rem;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
}

.countdown__done {
  margin: var(--space-3) 0 0;
  color: var(--accent);
}

/* Konfirmasi Kehadiran */

.rsvp {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  max-width: 520px;
  margin: 0 auto;
  padding: var(--space-3);
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  background: var(--panel);
  text-align: left;
}

.rsvp__field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.rsvp__label {
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
}

.rsvp__input {
  width: 100%;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--keyline);
  border-radius: 10px;
  background: rgba(0, 0, 0, 0.25);
  color: var(--ink);
  font: inherit;
}

.rsvp__radios {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  border: 0;
}

.rsvp__radios legend {
  margin-bottom: 0.35rem;
}

.rsvp__radio {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.95rem;
}

.rsvp__submit {
  align-self: flex-start;
  min-height: 44px;
  padding: 0.6rem 1.4rem;
  border: 1px solid var(--accent);
  border-radius: 999px;
  background: var(--accent);
  color: #10161a;
  font: inherit;
  cursor: pointer;
}

.rsvp__status {
  min-height: 1.2em;
  margin: 0;
  color: var(--accent);
  font-size: 0.9rem;
}

.rsvp__status.is-error {
  color: #e5857a;
}

/* List Ucapan */

.ucapan__count {
  margin: 0 0 var(--space-2);
  color: var(--muted);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.28em;
  text-transform: uppercase;
}

/* The list scrolls inside the slide, so the section never grows past one screen. dvh, like
   the slide: the list shares the one screen with the kicker and the title whatever the
   phone's toolbar is doing. */
.ucapan {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  max-height: 60vh;
  max-height: 60dvh;
  margin: 0 auto;
  padding: 0 0.5rem 0 0;
  overflow-y: auto;
  list-style: none;
  text-align: left;
  scrollbar-width: thin;
  scrollbar-color: var(--keyline) transparent;
  -webkit-overflow-scrolling: touch;
}

/* Set by the script only while the list is long enough to scroll: one card must not have
   its last line dimmed by a fade that has nothing to hide. */
.ucapan.is-scrollable {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 2rem), transparent);
}

.ucapan::-webkit-scrollbar {
  width: 4px;
}

.ucapan::-webkit-scrollbar-thumb {
  border-radius: 999px;
  background: var(--keyline);
}

/* Monogram on the left, everything else in a column: the card reads as one voice. */
.ucapan__item {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--space-2);
  padding: var(--space-2);
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0) 60%), var(--panel);
}

/* One script capital is the page's own ornament, and it gives a card an anchor without a
   photo. */
.ucapan__avatar {
  display: grid;
  place-items: center;
  width: 2.4rem;
  height: 2.4rem;
  border: 1px solid var(--keyline);
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.25);
  color: var(--accent);
  font-family: var(--font-display);
  font-size: 1.3rem;
  line-height: 1;
}

.ucapan__body {
  min-width: 0;
}

.ucapan__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.3rem 0.6rem;
  margin: 0 0 0.35rem;
}

.ucapan__name {
  font-size: 1.05rem;
  font-weight: 600;
}

/* The attendance is a status, not a sentence. */
.ucapan__meta {
  padding: 0.1rem 0.55rem;
  border: 1px solid var(--keyline);
  border-radius: 999px;
  color: var(--muted);
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* The wish itself: a quote behind a hairline, in the body face. */
.ucapan__msg {
  margin: 0 0 0.35rem;
  padding-left: 0.7rem;
  border-left: 1px solid var(--keyline);
  font-style: italic;
  line-height: 1.65;
  overflow-wrap: anywhere;
}

.ucapan__time {
  display: block;
  color: var(--muted);
  font-size: 0.72rem;
  letter-spacing: 0.08em;
}

.ucapan__empty {
  color: var(--muted);
}

/* A wish sent from this page lands with a short rise, so it is obvious where it went. */
@keyframes ucapan-in {
  from {
    opacity: 0;
    transform: translateY(0.5rem);
  }
}

.ucapan__item.is-new {
  animation: ucapan-in 500ms ease-out;
}

/* Wedding Gift */

/* A card, not a banner: the grid is capped and the column floor lowered, so four accounts
   settle into a compact 2x2 block of ~212px cards. The 180px floor is the number's own
   width — "1XXX XXXX XXXX X" in tabular figures — plus the card's padding, so it still
   fits one line. */
.gift {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
  gap: var(--space-2);
  max-width: 440px;
  margin: 0 auto;
}

/* The card is a surface: a soft diagonal wash, a corner sheen and a chip — the shape a
   guest already knows from their own card. Colour comes from the palette tokens only, so
   the accent never has to carry text on itself. */
.gift__card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.35rem;
  padding: 0.75rem var(--space-2);
  overflow: hidden;
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  background: linear-gradient(150deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0) 55%), var(--panel);
  text-align: left;
}

/* The sheen: a low-opacity accent disc off the bottom-right corner. */
.gift__card::after {
  content: "";
  position: absolute;
  right: -30%;
  bottom: -60%;
  z-index: 0;
  width: 9rem;
  height: 9rem;
  border-radius: 50%;
  background: radial-gradient(circle, var(--accent), transparent 70%);
  opacity: 0.16;
  pointer-events: none;
}

/* Everything the card says sits above the sheen. The copy button is absolute, so it carries
   its own stacking in its own rule below. */
.gift__chip,
.gift__name,
.gift__number {
  position: relative;
  z-index: 1;
}

.gift__chip {
  width: 1.4rem;
  height: 0.95rem;
  border: 1px solid var(--keyline);
  border-radius: 5px;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.04));
}

.gift__name {
  margin: 0;
  /* The holder shares the card's top row with the copy button, so it wraps before the
     button instead of running under it. 2.75rem clears the button's column (2.75rem wide,
     0.5rem in from the card's right edge) with room to spare; the number below keeps the
     full card width. */
  padding-right: 2.75rem;
  color: var(--muted);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
}

/* Tabular figures and a wide track: a number that has to be read back, digit by digit. The
   1rem cap is measured, not guessed: a two-up card is 212px wide and leaves the number
   178px, and the string needs about 171px at 1rem — at 1.1rem it broke onto two lines. */
.gift__number {
  margin: 0;
  font-size: clamp(0.95rem, 4.2vw, 1rem);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.1em;
  overflow-wrap: anywhere;
}

/* An icon button: the words the pill used to print live in the markup's aria-label and
   title, the glyph in an inline SVG. It rides the card's top-right corner, so the chip, the
   holder and the number stack in one column and no row is spent on the button. 2.75rem
   across — 44px, the touch floor from todo 023, kept although the glyph is smaller. */
.gift__copy {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  /* Above the card's sheen, which is .gift__card::after at z-index 0. */
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  min-height: 2.75rem;
  padding: 0;
  border: 1px solid var(--keyline);
  border-radius: 50%;
  background: transparent;
  color: var(--accent);
  cursor: pointer;
}

.gift__copy svg {
  width: 1.2rem;
  height: 1.2rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Two glyphs, one shown at a time: the clipboard while the button rests, the check for
   the flash. The script only toggles .is-copied, so it never has to write the button's
   content. */
.gift__copy-done {
  display: none;
}

.gift__copy.is-copied .gift__copy-icon {
  display: none;
}

.gift__copy.is-copied .gift__copy-done {
  display: block;
}

.gift__copy:hover,
.gift__copy:focus-visible {
  border-color: var(--accent);
}

.gift__copy.is-copied {
  border-color: var(--accent);
  background: var(--accent);
  color: #10161a;
}

/* Gallery — the tap trail below is the whole section. */

/* The trail is the content of this slide, so its label sits at the top rather than in the
   middle of an empty stage. The wash lifts the slide off its neighbours so it reads as a
   surface, and the three gesture rules keep a tap a tap: no text selection, no long-press
   callout, no double-tap zoom delay. manipulation still allows pan and pinch, so scrolling
   and zooming are untouched — and a drag draws nothing at all (see the script). */
.slide--gallery {
  align-items: flex-start;
  background-image: linear-gradient(rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.045));
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
  touch-action: manipulation;
}

/* Four words telling a guest how a photo arrives: one tap. Quiet and normal case: the kicker
   above already carries the tracked caps. */
.gallery-hint {
  margin: var(--space-1) 0 0;
  color: var(--muted);
  font-size: 0.85rem;
  letter-spacing: 0.06em;
  line-height: 1.6;
}

/* Tap trail — Hyperplexed's "Mousemove Image Gallery", drawn one tap at a time. Each tap
   lands the next photo where the finger went down and the oldest of six drops out, so six
   taps fill the stage. The layer is pointer-events: none, so nothing can be clicked through
   it, and overflow: hidden keeps a photo from ever reaching the next section. */
.gallery-trail {
  position: absolute;
  inset: 0;
  z-index: 2;
  overflow: hidden;
  pointer-events: none;
}

.gallery-trail__img {
  position: absolute;
  width: min(40vmin, 260px);
  transform: translate(-50%, -50%);
  border: 1px solid var(--keyline);
  border-radius: var(--radius);
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.45);
}

/* visibility, not display: the film grader finds an image through an IntersectionObserver
   and a display: none element never intersects, so these would stay ungraded. The
   data-status toggle is still what caps the stage at six photos. */
.gallery-trail__img[data-status="inactive"] {
  visibility: hidden;
}

.gallery-trail__img[data-status="active"] {
  display: block;
  /* No `both` fill: the resting rule above is already the animation's end state, so leaving it
     unfilled means nothing stays applied (and no animation keeps a layer alive) once the photo
     has landed. */
  animation: gallery-trail-in 420ms cubic-bezier(0.16, 1, 0.3, 1);
}

@keyframes gallery-trail-in {
  from {
    opacity: 0;
    transform: translate(-50%, -50%) scale(0.85);
  }

  to {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
  }
}

/* Thank You — the title is pinned to the top of the slide and the thanks words
   to the bottom, leaving the middle open to the backdrop. */

.thanks {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  /* dvh, not vh: see .slide. The foot is pinned to the bottom of this box, so it is
     the part a viewport taller than the screen used to hide. */
  min-height: calc(100vh - 2 * var(--slide-pad));
  min-height: calc(100svh - 2 * var(--slide-pad));
  min-height: calc(100dvh - 2 * var(--slide-pad));
  /* Clear of the fixed footer credit on a narrow, short viewport. */
  padding-bottom: var(--space-2);
}

.thanks__head {
  width: 100%;
}

.thanks__foot {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
  width: 100%;
}

.slide--thanks .section-title {
  margin-bottom: 0;
  font-size: clamp(1.6rem, 4.5vw, 2.4rem);
}

.thanks__text {
  margin: 0;
  font-size: clamp(0.85rem, 2vw, 1rem);
  line-height: 1.6;
}

.thanks__names {
  margin: var(--space-1) 0 0;
  color: var(--accent);
  font-family: var(--font-display);
  font-size: clamp(1.4rem, 4.5vw, 2rem);
  font-weight: 400;
  line-height: 1.25;
}

/* Reference-style copy blocks */
.mempelai__profile {
  margin: var(--space-1) 0;
  padding: 0;
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1.7;
  list-style: none;
}

.event__access {
  color: var(--muted);
  font-style: italic;
}

/* One sentence, and one line at every width. Body size plus 0.05em tracking broke the
   line after "sebelum" on a phone, so the size is tied to the viewport and the tracking
   gives back the rest: ~12.5px at 320px, ~14px at 390px, capped at 15.2px. It stays the
   highlighted sentence — weight and colour are unchanged. */
.rsvp__deadline {
  margin: 0 0 var(--space-1);
  color: var(--accent);
  font-size: clamp(0.78rem, 3.6vw, 0.95rem);
  font-weight: 600;
  letter-spacing: 0.01em;
}

.rsvp__note {
  max-width: 520px;
  margin: 0 auto var(--space-2);
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1.6;
}

.site-foot {
  position: fixed;
  bottom: 0.75rem;
  left: 50%;
  z-index: 3;
  transform: translateX(-50%);
  color: var(--muted);
  font-size: 0.75rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  opacity: 0.7;
  pointer-events: none;
}

/* Loading screen */
@keyframes loader-expire {
  to {
    opacity: 0;
    visibility: hidden;
  }
}

.loader {
  position: fixed;
  inset: 0;
  z-index: 9;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  background: var(--bg);
  transition: opacity 900ms cubic-bezier(0.39, 0.575, 0.565, 1);
  /* Failsafe: if the script never runs (JS enabled but broken), the overlay
     still clears itself. A pending animation does not fight the fade. */
  animation: loader-expire 400ms linear 6000ms forwards;
}

.loader.is-out {
  opacity: 0;
  pointer-events: none;
}

.loader__names {
  margin: 0;
  color: var(--accent);
  font-family: var(--font-display);
  font-size: clamp(2rem, 9vw, 4rem);
  line-height: 1.2;
}

.loader__kicker {
  margin: 0;
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 600;
  letter-spacing: 0.35em;
  text-transform: uppercase;
}

.loader__bar {
  width: min(60vw, 240px);
  height: 2px;
  margin-top: var(--space-2);
  overflow: hidden;
  background: var(--keyline);
}

.loader__fill {
  display: block;
  width: 0;
  height: 100%;
  background: var(--accent);
  transition: width 300ms linear;
}

.loader__pct {
  margin: 0;
  color: var(--muted);
  font-size: 0.75rem;
  letter-spacing: 0.2em;
}

/* If AOS never boots, keep reveal targets visible. */
html:not(.aos-ready) [data-aos] {
  opacity: 1 !important;
  transform: none !important;
}

/* Mobile pass */
@media (max-width: 480px) {
  .dots {
    right: 0.5rem;
  }

  .hero__top {
    max-width: 70%;
  }

  .hero__sign {
    font-size: 1rem;
    letter-spacing: 0.08em;
  }

  .hero__guest {
    font-size: 0.9rem;
  }

  .events,
  .gift {
    grid-template-columns: 1fr;
  }

  .ucapan {
    max-height: 52vh;
    max-height: 52dvh;
  }

  .countdown {
    gap: var(--space-1);
  }

  .countdown__tile {
    padding: var(--space-1) 0.25rem;
  }

  .site-foot {
    position: static;
    margin: 0 auto var(--space-2);
    transform: none;
    text-align: center;
    opacity: 0.6;
  }
}

@media (prefers-reduced-motion: reduce) {
  .dots button::before {
    transition: none;
  }

  .gallery-trail__img[data-status="active"] {
    animation: none;
  }

  .ucapan__item.is-new {
    animation: none;
  }

  .loader,
  .loader__fill {
    transition: none;
  }
}
