/* Very al Dente — veryaldente.com
   Hand-written, no build step, no third-party requests. See web/README.md. */

/* The app's own palette, nothing else: the creta warm-neutral ramp from
   ios/VeryAlDente/Palette.swift, one token per Figma variable. The system has
   no accent — it is neutral plus one functional red — so links are foreground
   and underlined rather than tinted.

   Appearance has three states. No data-theme = follow the system; the toggle
   in the header writes data-theme="light" or "dark" and that wins in BOTH
   directions, which is why the dark values are declared twice. Never give a
   colour its only definition inside the media query. */
:root {
  color-scheme: light;
  --bg: #ffffff;          /* creta 0  · background */
  --fg: #1b1917;          /* creta 17 · foreground */
  --muted: #6d6760;       /* creta 13 · bodyText */
  --faint: #9d938a;       /* creta 11 · faintText */
  --rule: #d7d0c6;        /* creta 7  · border */
  --card: #f6f4f2;        /* creta 2  · card */
  --tile: #edeae4;        /* creta 4  · surface */
  --link: #1b1917;
  --table-head: #f6f4f2;
  --lift: 24px 44px 70px -34px rgba(27, 25, 23, 0.42);
  --cut-shadow: 0 16px 26px rgba(27, 25, 23, 0.16);
  --card-lift: 0 34px 64px -34px rgba(27, 25, 23, 0.3), 0 2px 6px rgba(27, 25, 23, 0.05);
  /* The footer is ink in both appearances: the page's floor. In dark it goes
     a step deeper than the page so it still reads as a floor. */
  --footer-bg: #1b1917;
  --footer-fg: #faf9f8;
  --footer-muted: #b0a699;
  /* No halo on the wordmark (Joost, 2026-08-23). Kept as a token at none so
     the two text-shadow declarations need not move. */
  --wordmark-halo: none;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #1b1917;        /* creta 17 */
    --fg: #faf9f8;        /* creta 1  */
    --muted: #b0a699;     /* creta 10 */
    --faint: #847d75;     /* creta 12 */
    --rule: #433f3a;      /* creta 15 */
    --card: #2e2c28;      /* creta 16 */
    --tile: #2e2c28;      /* creta 16 */
    --link: #faf9f8;
    --table-head: #2e2c28;
    --lift: 24px 44px 70px -34px rgba(0, 0, 0, 0.8);
    --cut-shadow: 0 16px 26px rgba(0, 0, 0, 0.45);
    --card-lift: 0 34px 64px -34px rgba(0, 0, 0, 0.75), 0 2px 6px rgba(0, 0, 0, 0.3);
    --wordmark-halo: none;
    --footer-bg: #121110;
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #1b1917;
  --fg: #faf9f8;
  --muted: #b0a699;
  --faint: #847d75;
  --rule: #433f3a;
  --card: #2e2c28;
  --tile: #2e2c28;
  --link: #faf9f8;
  --table-head: #2e2c28;
  --lift: 24px 44px 70px -34px rgba(0, 0, 0, 0.8);
  --cut-shadow: 0 16px 26px rgba(0, 0, 0, 0.45);
  --card-lift: 0 34px 64px -34px rgba(0, 0, 0, 0.75), 0 2px 6px rgba(0, 0, 0, 0.3);
  --wordmark-halo: none;
  --footer-bg: #121110;
}

* {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
  /* clip, not hidden: hidden leaves the page programmatically scrollable
     sideways (and swipe-scrollable in places), clip forbids it outright —
     and it never turns the element into a scroll container, which matters
     for every sticky layer inside. */
  overflow-x: clip;
}

body {
  margin: 0;
  padding: 3rem 1.25rem 5rem;
  background: var(--bg);
  color: var(--fg);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  font-size: 1.0625rem;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

main {
  max-width: 38rem;
  margin: 0 auto;
}

/* Site name above the page title, doubles as the way back to the root. */
.site {
  display: inline-block;
  margin-bottom: 1.75rem;
  color: var(--muted);
  font-size: 0.9375rem;
  text-decoration: none;
}

.site:hover {
  color: var(--fg);
}

h1 {
  margin: 0 0 1.5rem;
  font-size: 2rem;
  line-height: 1.2;
  letter-spacing: -0.02em;
  font-weight: 600;
}

h2 {
  margin: 2.75rem 0 0.75rem;
  font-size: 1.1875rem;
  line-height: 1.3;
  font-weight: 600;
}

p {
  margin: 0 0 1rem;
}

ul {
  margin: 0 0 1rem;
  padding-left: 1.25rem;
}

li {
  margin-bottom: 0.375rem;
}

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

a {
  color: var(--link);
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

.meta {
  color: var(--muted);
  font-size: 0.9375rem;
  margin-bottom: 2rem;
}

/* The processor table. Long cells, so it wraps rather than scrolls where it
   can, with overflow as a backstop on very narrow screens. */
.table-wrap {
  overflow-x: auto;
  margin: 0 0 1rem;
  -webkit-overflow-scrolling: touch;
}

table {
  border-collapse: collapse;
  width: 100%;
  min-width: 20rem;
  font-size: 0.9375rem;
  line-height: 1.45;
}

th,
td {
  border: 1px solid var(--rule);
  padding: 0.5rem 0.625rem;
  text-align: left;
  vertical-align: top;
}

th {
  background: var(--table-head);
  font-weight: 600;
}

@media (max-width: 30rem) {
  body:not(.landing) {
    padding-top: 2.25rem;
  }

  body:not(.landing) h1 {
    font-size: 1.75rem;
  }
}

/* --------------------------------------------------------------------------
   /privacy and /support. They wear the landing's chrome — same header, same
   footer, same palette — but keep the document typography above for the body,
   because they are long-form reading and the landing's measure is not. .doc
   opts out of everything the hero needs. */
.landing.doc {
  padding: 0;
}

.landing.doc main {
  max-width: 44rem;
  margin: 0 auto;
  padding: clamp(6rem, calc(0.14 * var(--sh)), 9rem) var(--edge) clamp(4rem, calc(0.10 * var(--sh)), 7rem);
}

.landing.doc h1 { font-size: clamp(2rem, 4vw, 3rem); line-height: 1.05; margin-bottom: 1.75rem; }
.landing.doc h2 { font-size: 1.25rem; margin: 2.75rem 0 0.75rem; line-height: 1.3; }
.landing.doc p { margin: 0 0 1rem; }
.landing.doc a { color: var(--link); text-decoration: underline; text-underline-offset: 0.15em; }
.landing.doc .site-footer a { text-decoration: none; }
.landing.doc .footer-mark,
.landing.doc .wordmark { text-decoration: none; }

/* ==========================================================================
   The landing page (/), scoped to body.landing so /privacy and /support keep
   the plain document styles above untouched.

   Proportions are lifted from a reference one-pager and kept: 1080 content
   width, 48px gutters, 24px gaps, 24px card radius, 12px buttons, and 256px
   between blocks. Type runs 96 / 64 / 48 / 32 at weight 600 and -0.03em, all
   at line-height 1 except the hero.

   Motion is CSS scroll-driven — no JavaScript, nothing to load, and it runs on
   the compositor because only transform and opacity ever change. Every element
   is written in its FINISHED state, and the animation is layered on top inside
   @supports. A browser without scroll timelines, or a reader who asked for
   reduced motion, gets the same page fully composed and completely still.
   ========================================================================== */
.landing {
  --page-w: 67.5rem;
  --edge: clamp(1.25rem, 4.07vw, 3rem);
  --gap: clamp(0.75rem, 2.03vw, 1.5rem);
  --radius: 1.5rem;
  --block: clamp(6rem, 21.7vw, 16rem);

  /* Hero tunables. The ?tune panel writes these; the values here are the
     committed design. Keep them in sync when a setting is adopted. */
  --h1-size: clamp(2.1rem, 3.5vw, 5.25rem);
  /* The second line sits in a crowd (phone, body, badge, note): smaller. */
  --h2-size: clamp(1.5rem, 2.1vw, 3rem);
  --h1-track: -0.035em;
  --h1-leading: 1.02;
  --wordmark-y: 18px;
  --wordmark-size: 17px;
  /* ONE centre for the whole hero: where the bouquet rests, and where the
     phone grows. They have to be the same point or the phone does not come
     out of the ingredients — it just turns up somewhere else. Everything else
     hangs off this: the opening line below the bouquet's reach, the second
     line and the badge below the phone's bottom edge. Chosen so the settled
     column sits centred on a tall window. */
  --stage-y: 42%;
  /* One cutout's width, and the resting bouquet's reach from its centre
     derived from it — half a piece plus the tallest resting offset. Derived,
     not measured, so widening --cut can't silently push the cluster down onto
     the opening line. */
  /* ONE screen height, and the only place a viewport-height unit is allowed
     to enter the hero. On a phone the script freezes it to a pixel value at
     load (--vh-lock): Arc and Chrome on iOS resize the web view when their
     toolbar hides, so anything written in svh/vh re-lays out on every scroll
     direction change. That was the "jitter" (VAD-597, five recordings). */
  --sh: 100svh;
  --cut: min(clamp(7rem, 14.5vw, 17rem), calc(0.26 * var(--sh)));
  --bouquet-r: calc(0.62 * var(--cut) + var(--pack, 1) * 5rem);
  /* The drop from the phone's bottom edge to the second line and the badge. */
  --under-gap: 3.8rem;
  /* How much scroll passes with the composition finished and STILL before the
     hero starts moving up. The well is the sum of what has to happen in it —
     one screen of pin, the reveal, then the dwell — so raising the dwell adds
     hold without touching the pace of anything. */
  --dwell: calc(0.10 * var(--sh));
  --well-h: calc(var(--sh) + var(--reveal) + var(--dwell));
  /* How much scroll the reveal itself takes — bouquet opening, phone
     arriving, headline crossfading, badge landing. Everything in the hero is
     ranged against THIS, as a length, not as a percentage of the well. So the
     leftover (well minus one screen minus this) is dwell: the composition
     sits finished and still before the page takes it away, and lengthening
     the dwell is a one-token change that does not speed anything up or slow
     anything down. */
  --reveal: 46vh;
  /* How much of the release is spent matching the page's speed. One viewport
     is the whole exit phase and the smoothest it can be; shorten it for a
     later, sharper pickup. It cannot usefully exceed 100svh. */
  --ramp: var(--sh);
  /* Badge and QR button share a height and a radius — they read as one
     control, and both are deliberately heavy. */
  --hint-y: 7rem;
  --hint-size: 1.35rem;
  --hint-idle: 0.5;
  --hint-hover: 0.95;
  --chunk: 3.55rem;
  --chunk-radius: 1rem;
  /* Dead space above the first field cutout. Without it they show up while
     the bouquet is still coming apart, and the two populations read as one
     crowd rather than one following the other. */
  --field-lead: 500px;
  /* Where the opening line sits as it leaves, and where the second line comes
     FROM as it arrives. Positive is DOWN in both. The second used to borrow
     the first's number negated, which is why it dropped in from above the
     moment the first started drifting downward — they are independent now. */
  --h1-y-from: 0vh;
  --h1-y-to: calc(0.04 * var(--sh));
  --h2-y-from: calc(0.04 * var(--sh));
  --h2-y-to: 0vh;
  --stage-drift: calc(-0.04 * var(--sh));
  /* Fast away, then easing off. One knob: raise the second number to make the
     opening faster and the tail longer. */
  --explode-ease: cubic-bezier(0, 0.54, 0.41, 1);  /* bouquet + phone travel up together */
  /* The phone's WIDTH, and the height cap is what actually governs it: the
     settled column is phone + line + badge and all three have to fit one
     screen with the header cleared. Raising this past the cap does not make
     the phone bigger, it makes the column overflow.

     The cap is the height left over rather than a flat fraction of the screen,
     which matters because --under-gap is a knob: 2.169 is the screenshot's
     aspect ratio (640 × 1388), 6rem clears the header and the bottom margin,
     and 10rem stands in for the second line and its gap. So widening the drop
     below the phone automatically buys the room out of the phone instead of
     off the bottom of the window — and on a tall screen the rem clamp still
     wins, so none of this touches a big display. */
  --phone-size: min(
    clamp(10rem, 18vw, 19rem),
    calc((var(--sh) - 6rem - var(--under-gap) - 14rem - var(--chunk)) / 2.169)
  );
  /* The card. Landscape, text left, phone right, the same on every one — the
     ?tune panel writes all of these. --slab-shot is a fraction of the card's
     HEIGHT, and the card's height is width ÷ ratio, which is why the shot can
     be sized off two tokens without measuring anything. */
  --slab-w: 36rem;
  --slab-ratio: 1.35;
  --slab-radius: 2.5rem;
  --slab-pad: 2.75rem;
  --slab-gap: 2.25rem;
  --slab-shot: 1.05;
  --slab-shot-y: 0%;
  --slab-copy-w: 22ch;
  --deck-gap: 13rem;
  /* Hero → first card. */
  --deck-lead: calc(-0.20 * var(--sh));
  /* start | center | end — where the copy sits against the CARD. */
  --slab-copy-align: center;
  /* Where the phone hangs from: 0% top, 50% centre, 100% bottom. TOP by
     default — its top edge stays in view and it runs off the foot of the card,
     rather than being trimmed at both ends. */
  --shot-anchor: 0%;
  --t-slab: clamp(1.17rem, 1.45vw, 1.47rem);
  --t-say: clamp(2rem, 5.42vw, 4rem);
  --t-lead: clamp(1.6rem, 4.07vw, 3rem);
  --t-card: clamp(1.3rem, 2.71vw, 2rem);

  padding: 0;
  overflow-x: clip;
}

.landing h1,
.landing h2,
.landing .headline-second,
.landing .endnote-line,
.landing .line {
  margin: 0;
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1;
  text-wrap: balance;
}

/* The opening line keeps its own size; the second line and the closing line
   share the smaller one (they sit in crowded company). */
.landing h1 { font-size: var(--h1-size); line-height: var(--h1-leading); letter-spacing: var(--h1-track); }
.landing .headline-second,
.landing .endnote-line { font-size: var(--h2-size); line-height: var(--h1-leading); letter-spacing: var(--h1-track); }
.landing .headline-second-phone { display: none !important; }
.landing h2 { font-size: var(--t-say); }
.landing .line { font-size: var(--t-card); }
.landing .line.big { font-size: var(--t-lead); }
.landing p { margin: 0; }
.landing img { display: block; max-width: 100%; height: auto; }

.landing .page {
  width: min(var(--page-w), 100%);
  margin-inline: auto;
  padding-inline: var(--edge);
}

.landing .section { padding-bottom: var(--block); }

/* Buttons are 12px rectangles, not pills. */
.landing .cta-row {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  justify-content: center;
}

.landing .cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 2.75rem;
  padding: 0 2rem;
  border: 1px solid transparent;
  border-radius: 0.75rem;
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1;
  text-decoration: none;
  transition: background 170ms ease, color 170ms ease, border-color 170ms ease;
}

.landing .cta.solid { background: var(--fg); color: var(--bg); }
.landing .cta.solid:hover,
.landing .cta.solid:focus-visible { background: transparent; color: var(--fg); border-color: var(--fg); }
.landing .cta.ghost { color: var(--fg); border-color: var(--rule); }
.landing .cta.ghost:hover,
.landing .cta.ghost:focus-visible { background: var(--fg); color: var(--bg); border-color: var(--fg); }

/* --- 1 · Hero ------------------------------------------------------------
   The cutouts are a bouquet at rest and a scatter when the scroll is done.
   Each one carries its own vector (--dx/--dy), speed (--k) and size (--s), so
   the same keyframe pair throws all fourteen apart at different rates.

   Three sticky layers stacked by z-index, and the order matters:
     1  .phone-layer    behind the cutouts, so the phone is UNCOVERED
     3  .cluster-layer  the cutouts, pinned to the whole stage
     5  .hero           the two lines and the badge, always legible
   The phone must come OUT of the bouquet, which means being behind it and
   sharing its centre. Getting it fully in view is a travel problem, not a
   z-order one: the pieces' vectors clear its silhouette by the end of the
   reveal. Putting the phone in front instead "fixes" the overlap and throws
   away the reveal — don't. */
/* --- Arriving ------------------------------------------------------------
   The header and the hero start invisible and fade in once their images and
   the fonts are ready, so the first thing a visitor sees is the finished
   composition rather than pieces landing one by one. The class is removed by
   script; a capped wait means a slow image can never hold the page hostage.
   Opacity only, and it settles at 1, so nothing here fights the scroll driven
   animations underneath it. */
.vad-preload .landing .topbar,
.vad-preload .landing .stage { opacity: 0; }

.landing .topbar,
.landing .stage { transition: opacity 600ms ease-out; }

@media (prefers-reduced-motion: reduce) {
  .landing .topbar,
  .landing .stage { transition: none; }
}

.landing .stage { position: relative; }
.landing .well { position: relative; }

.landing .hero {
  position: relative;
  min-height: var(--sh);
  overflow: hidden;
  display: grid;
}

.landing .cluster-layer,
.landing .phone-layer { display: none; }

/* --- Header ---------------------------------------------------------------
   Fixed, so the name rides the whole page. The bar itself takes no pointer
   events — it spans the full width and would otherwise swallow clicks on
   everything beneath it — and hands them back to its two children. */
.landing .topbar {
  position: fixed;
  inset: 0 0 auto;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--wordmark-y) var(--edge);
  pointer-events: none;
}

/* The bar itself is fully transparent — no wash, no gradient. What keeps the
   name readable over a passing cutout is a whisper of page colour behind the
   letters instead, which costs nothing when there is nothing behind them. */
.landing .topbar > * { position: relative; pointer-events: auto; }

.landing .wordmark {
  margin: 0;
  color: var(--fg);
  text-decoration: none;
  font-size: var(--wordmark-size);
  font-weight: 600;
  letter-spacing: -0.01em;
  text-shadow: var(--wordmark-halo);
}

/* Both controls travel as one group, anchored to the corner, so the appearance
   switch keeps the exact position it had on its own and the language sits to
   its left. The page gutter is 48px on a wide screen, which reads as "near the
   corner" rather than in it — matched to the header's own top inset instead. */
.landing .topbar .controls {
  position: absolute;
  right: var(--wordmark-y);
  display: flex;
  align-items: center;
  gap: 0.15rem;
}

/* --- Language ------------------------------------------------------------
   A <details>: the open/close, the button role and the keyboard all come from
   the browser. The button carries the current code, the panel spells every
   language out in its own words. */
/* Deliberately NOT positioned: the panel hangs off the controls group instead,
   so its right edge lands on the page's right gutter, in line with the
   appearance switch, rather than tucked under the narrower language button. */
.landing .lang {
  position: static;
}

.landing .lang-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.05rem;
  padding: 0.35rem 0.2rem 0.35rem 0.4rem;
  border-radius: 0.6rem;
  color: var(--fg);
  font-size: var(--wordmark-size);
  font-weight: 600;
  line-height: 1;
  list-style: none;          /* and the two below: no disclosure triangle */
  cursor: pointer;
  opacity: 0.7;
  transition: opacity 180ms ease;
  -webkit-tap-highlight-color: transparent;
}

.landing .lang-btn::-webkit-details-marker { display: none; }
.landing .lang-btn::marker { content: ""; }

/* Same whisper of page colour behind the letters that keeps the wordmark
   readable when a cutout passes under it. */
.landing .lang-code {
  text-transform: uppercase;
  letter-spacing: 0.02em;
  text-shadow: var(--wordmark-halo);
}

.landing .lang-caret {
  width: 18px;
  height: 18px;
  transition: rotate 220ms cubic-bezier(0.2, 0.65, 0.2, 1);
}

.landing .lang-btn:hover,
.landing .lang-btn:focus-visible,
.landing .lang[open] .lang-btn { opacity: 1; }
.landing .lang[open] .lang-caret { rotate: 180deg; }

.landing .lang-menu {
  position: absolute;
  top: calc(100% + 0.55rem);
  right: 0;
  min-width: 11.5rem;
  margin: 0;
  padding: 0.3rem;
  list-style: none;
  border: 1px solid var(--rule);
  border-radius: 1rem;
  background: var(--card);
  box-shadow: var(--card-lift);
}

.landing .lang-menu a,
.landing .lang-menu .theme-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.5rem 0.55rem 0.5rem 0.7rem;
  border-radius: 0.7rem;
  color: var(--muted);
  font-size: 0.9375rem;
  line-height: 1.2;
  text-decoration: none;
  /* NOT --tile: in dark it is the same colour as --card, so a hover painted
     with it would be invisible. A wash of the foreground works in both. */
  transition: background-color 140ms ease, color 140ms ease;
}

.landing .lang-menu a:hover,
.landing .lang-menu a:focus-visible,
.landing .lang-menu .theme-row:hover,
.landing .lang-menu .theme-row:focus-visible {
  background: color-mix(in srgb, var(--fg) 9%, transparent);
  color: var(--fg);
}

.landing .lang-menu a:focus-visible,
.landing .lang-menu .theme-row:focus-visible { outline: 2px solid var(--fg); outline-offset: -2px; }
.landing .lang-menu [aria-current] { color: var(--fg); font-weight: 600; }

/* Drawn on every row and revealed only on the current one, so choosing a
   language cannot change the width of the panel. */
.landing .lang-tick { flex: none; width: 17px; height: 17px; opacity: 0; }
.landing .lang-menu [aria-current] .lang-tick { opacity: 1; }

/* The phone-only pieces of the menu: the three dots in the button, the rule
   and the two appearance rows. All off until the phone block turns them on. */
.landing .lang-dots { display: none; width: 22px; height: 22px; }
.landing .menu-sep { display: none; height: 1px; margin: 0.3rem 0.45rem; background: var(--rule); }
.landing .theme-item { display: none; }
.landing .lang-menu .theme-row {
  width: 100%;
  margin: 0;
  border: 0;
  background: none;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* Both header controls are drawn small on purpose, and small is fine for a
   mouse. On a finger they are 29px and 34px tall against a 44px minimum, so on
   a touch screen the rows grow and the two buttons get an invisible 44px band
   around them — the hit area moves, never the drawing. */
@media (pointer: coarse) {
  .landing .lang-btn,
  .landing .mode { position: relative; }

  .landing .lang-btn::after,
  .landing .mode::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    height: 44px;
    translate: 0 -50%;
  }

  .landing .lang-menu a,
  .landing .lang-menu .theme-row { padding-block: 0.85rem; }
}

@media (prefers-reduced-motion: no-preference) {
  .landing .lang[open] .lang-menu {
    animation: vad-lang-open 160ms cubic-bezier(0.2, 0.65, 0.2, 1) both;
  }
}

@keyframes vad-lang-open {
  from { opacity: 0; translate: 0 -6px; }
}

/* Just the icon — no border, no fill, no button chrome. It flips rather than
   swapping glyphs: one Central icon rotated 180°, so the filled half is on the
   right in light and on the left in dark, and the change itself is the
   animation. */
.landing .mode {
  display: inline-flex;
  padding: 0.35rem;
  border: 0;
  background: none;
  color: var(--fg);
  cursor: pointer;
  opacity: 0.7;
  rotate: 0deg;
  transition: opacity 180ms ease, rotate 320ms cubic-bezier(0.2, 0.65, 0.2, 1);
}

.landing .mode:hover,
.landing .mode:focus-visible { opacity: 1; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .landing .mode { rotate: 180deg; }
}

:root[data-theme="dark"] .landing .mode { rotate: 180deg; }

/* The opening line sits UNDER the resting bouquet, hung off the same anchor as
   everything else: the cluster's centre plus its reach downward. */
.landing .headline-first {
  position: absolute;
  top: calc(var(--stage-y) + var(--bouquet-r) + 1.5rem);
  left: 50%;
  translate: -50% 0;
  width: min(60rem, 100% - var(--edge) * 2);
  text-align: center;
}

/* The scroll hint: Central's chevron, parked under the opening line. It is an
   invitation, so it leaves as soon as the invitation is taken — and it is a
   real button, because a thing that looks clickable should be.

   The resting opacity lives on the SVG, not here: this element's opacity is
   already spoken for by the exit animation it shares with the opening line,
   and an animation beats a declaration. Nesting them multiplies, which is what
   we want — hover brightens the chevron without disturbing its fade. */
.landing .scroll-hint {
  position: absolute;
  bottom: var(--hint-y);
  left: 50%;
  translate: -50% 0;
  padding: 0.6rem;
  border: 0;
  background: none;
  color: var(--fg);
  cursor: pointer;
  transition: scale 200ms cubic-bezier(0.2, 0.65, 0.2, 1);
}

.landing .scroll-hint:hover,
.landing .scroll-hint:focus-visible { scale: 1.1; }

.landing .scroll-hint svg {
  display: block;
  width: var(--hint-size);
  height: var(--hint-size);
  opacity: var(--hint-idle);
  transition: opacity 200ms ease;
}

.landing .scroll-hint:hover svg,
.landing .scroll-hint:focus-visible svg { opacity: var(--hint-hover); }

/* The second line and the badge hang off the phone's bottom edge — half the
   phone's height below its centre, plus a gap. The 1.0845 is the screenshot's
   aspect ratio halved (640 × 1388), so this tracks --phone-size on its own and
   the three never need hand-fitting against each other. */
.landing .under {
  position: absolute;
  top: calc(var(--stage-y) + var(--phone-size) * 1.0845 + var(--under-gap));
  left: 50%;
  translate: -50% 0;
  width: min(60rem, 100% - var(--edge) * 2);
  display: grid;
  justify-items: center;
  gap: 1.25rem;
  text-align: center;
  pointer-events: none;
}

/* The line is decorative — aria-hidden, nothing to click — and it sits right
   over the chevron. Left hittable it swallowed the chevron's hover AND its
   click, which is exactly what it was doing. */
.landing .under > * { pointer-events: auto; }
.landing .headline-second { pointer-events: none; }

/* Under the second line, above the badge. Hidden with the second line on a
   page without scroll timelines, and it arrives with the badge. */
.landing .headline-body {
  display: none;
  /* Tied to the headline's size, so it stays a touch narrower than the
     line above it at every width. */
  /* Was 10 x --h1-size; pinned to the same computed width so restoring the
     opening line's size does not widen the body under it. */
  max-width: clamp(18.5rem, 30vw, 45rem);
  margin-bottom: 0.75rem;
  color: var(--muted);
  font-size: clamp(1rem, 1.15vw, 1.2rem);
  line-height: 1.5;
  text-wrap: pretty;
}

/* The bouquet and the phone drift up toward the centre while the headline is
   on its way out, so the composition closes in rather than sitting low. Both
   layers run the same keyframe off the same timeline, which is what keeps them
   locked to each other. */
.landing .cluster,
.landing .phone-layer,
.landing .under {
  animation: vad-stage-rise linear both;
  animation-timeline: scroll(root);
  animation-range: 0px var(--reveal);
}

/* Two headlines, one cell: the first goes as the bouquet opens, the second
   arrives with the phone. Without scroll timelines only the first exists, so
   the still page still reads as a finished hero. */
.landing .headlines { display: grid; }
.landing .headlines > * { grid-area: 1 / 1; }
.landing .headline-second { display: none; }

.landing .cluster {
  position: absolute;
  left: 50%;
  top: var(--stage-y);
  width: 0;
  height: 0;
}

.landing .drift {
  position: absolute;
  left: 0;
  top: 0;
  width: calc(var(--s, 1) * var(--cut));
  /* .cluster is a zero-size anchor point, so the inherited max-width: 100%
     would collapse every cutout to nothing. */
  max-width: none;
  pointer-events: none;
  filter: drop-shadow(var(--cut-shadow));
  /* Depth. Bigger piece = nearer = travels faster than the page. Derived from
     --s rather than authored per piece, so the arrangement and the parallax
     can never disagree, and the FIELD below uses the same rule off --fs — the
     two populations share one depth model or they stop reading as one space. */
  --d: calc(1 + (var(--s, 1) - 0.95) * 0.6);
  /* The explosion drives translate/rotate/scale, NOT transform. That is
     deliberate and load-bearing: transform is left free for the parallax, so
     the two can animate the same element without fighting. Fold anything new
     into these three rather than reaching for transform. */
  translate:
    calc(-50% + var(--ox) * var(--pack, 1) + var(--dx) * var(--k) * var(--spread, 1))
    calc(-50% + var(--oy) * var(--pack, 1) + var(--dy) * var(--k) * var(--spread, 1));
  rotate: var(--r);
  /* --end-k shrinks the EXPLODED state only — the resting bouquet is the
     keyframe's own `from` and never reads it. 0.85 on desktop, 0.8 on a
     phone (the mobile block below). */
  --end-k: 0.62;
  scale: calc((0.9 + 0.55 * var(--k)) * var(--end-k, 1));
}

/* The phone never travels. It sits at the bouquet's own centre and grows out
   of it as the pieces leave. Being fully in view is not a z-order problem —
   it is a travel problem, and the fix is in the cutouts' vectors: every one of
   them now clears the phone's silhouette by the end of the reveal. */
.landing .hero-phone {
  position: absolute;
  left: 50%;
  top: var(--stage-y);
  width: var(--phone-size);
  max-width: none;
  translate: -50% -50%;
  /* A fraction of its own width, so the corner stays right at any size —
     a fixed rem reads sharp on a big phone and blobby on a small one. */
  border-radius: calc(var(--phone-size) * 0.135);
  box-shadow: var(--lift);
}

/* --- The badge and the QR ------------------------------------------------
   Both are one chunky pair: same height, same radius, same fill, drawn rather
   than supplied.

   ⚠️ This is NOT Apple's badge artwork. Apple's identity guidelines ask you to
   use the lockup they ship, and we did until this was made deliberately
   heavier to match the rest of the page. Their files are still in the repo —
   `appstore-badge-black.svg` / `-white.svg` — so going back is swapping this
   block for two <img>s. Nothing in App Store review looks at this page, but it
   is a guideline we are knowingly off. */
.landing .badge {
  display: inline-flex;
  align-items: center;
  gap: 0.65rem;
  height: var(--chunk);
  padding: 0 1.4rem 0 1.15rem;
  border-radius: var(--chunk-radius);
  background: var(--fg);
  color: var(--bg);
  text-decoration: none;
  transition: transform 170ms cubic-bezier(0.2, 0.65, 0.2, 1);
}

.landing .badge:hover,
.landing .badge:focus-visible { transform: translateY(-2px); }

.landing .badge .apple {
  width: 1.4rem;
  height: auto;
  fill: currentColor;
  /* The mark's optical centre sits above its box centre. */
  margin-top: -0.12rem;
}

.landing .badge-text {
  display: grid;
  gap: 0.1rem;
  text-align: left;
  line-height: 1;
}

.landing .badge-text small { font-size: 0.72rem; font-weight: 450; letter-spacing: -0.005em; }
.landing .badge-text strong { font-size: 1.1rem; font-weight: 600; letter-spacing: -0.02em; }

/* --- QR ------------------------------------------------------------------
   A square sibling of the badge that reveals a scan card on hover or focus.
   Two files: `qr-mark.svg` is a real but tiny code used purely as the button's
   glyph (the 29-module data code turns to mush at this size), `qr.svg` is the
   one that actually resolves. Both are painted with a mask rather than shown
   as an <img>, which is what lets them follow the theme. Below 40rem the
   reader is already holding the phone, so none of it is there at all. */
.landing .qr { position: relative; display: none; }

@media (min-width: 40rem) {
  .landing .qr { display: block; }
}

.landing .qr-btn {
  display: grid;
  place-items: center;
  width: var(--chunk);
  height: var(--chunk);
  padding: 0;
  border: 0;
  border-radius: var(--chunk-radius);
  background: var(--fg);
  color: var(--bg);
  cursor: pointer;
  transition: transform 170ms cubic-bezier(0.2, 0.65, 0.2, 1);
}

.landing .qr:hover .qr-btn,
.landing .qr-btn:focus-visible { transform: translateY(-2px); }

.landing .qr-btn::before {
  content: "";
  display: block;
  width: 2rem;
  height: 2rem;
  background: currentColor;
  -webkit-mask: url(/qr-mark.svg) center / contain no-repeat;
  mask: url(/qr-mark.svg) center / contain no-repeat;
}

.landing .qr-pop {
  position: absolute;
  bottom: calc(100% + 0.8rem);
  left: 50%;
  translate: -50% 0.5rem;
  width: 15.5rem;
  padding: 1.25rem;
  background: var(--bg);
  border: 1px solid var(--rule);
  border-radius: 1.75rem;
  box-shadow: var(--lift);
  text-align: center;
  opacity: 0;
  visibility: hidden;
  transition: opacity 160ms ease, translate 160ms ease, visibility 160ms;
}

.landing .qr:hover .qr-pop,
.landing .qr:focus-within .qr-pop { opacity: 1; visibility: visible; translate: -50% 0; }

.landing .qr-pop p {
  margin-bottom: 0.9rem;
  color: var(--muted);
  font-size: 0.95rem;
  line-height: 1.2;
}

.landing .qr-code {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  background: var(--fg);
  -webkit-mask: url(/qr.svg) center / contain no-repeat;
  mask: url(/qr.svg) center / contain no-repeat;
}

/* --- The field ------------------------------------------------------------
   Cutouts below the hero that are NOT part of the explosion. They are plain
   page content with no animation at all, which is the entire point: content
   in flow moves at exactly 1× scroll, and the released cutouts above are also
   at exactly 1× by the time they arrive, so the two populations read as one.
   Give these a parallax and the illusion dies. */
/* No z-index here, deliberately. It used to be 0, which made .field a stacking
   context — and nothing inside a stacking context can outrank a sibling of the
   context itself, so the deck was pinned below the hero's released cutouts at
   3 no matter what number it carried. That is why the ingredients scrolled
   over the cards. The deck now competes at the root instead. */
.landing .field {
  position: relative;
  margin-top: var(--field-lead);
  /* Just enough to clear the last card — the closing note carries its own
     padding, and stacking the two is what left a dead screen in between. */
  padding-block: 0 calc(0.04 * var(--sh));
}

/* --- The deck ------------------------------------------------------------
   One card format, repeated and flipped. Not a bento of mixed shapes: the
   cutouts around them are already doing the irregularity, and two competing
   rhythms read as noise. Monotone for the same reason — every card is --card,
   nothing carries a colour of its own, so hue belongs to the illustrations
   alone. The zigzag comes from :nth-child(even), not from a second class. */
.landing .deck {
  position: relative;
  /* Above the hero's released cutouts (3), below the hero itself (5). */
  z-index: 4;
  display: grid;
  /* A MARGIN, not the field's padding, because this has to be able to go
     negative: --field-lead already holds the cutouts back half a screen, and
     "closer than zero" means eating into that. */
  margin-top: var(--deck-lead);
  gap: var(--deck-gap);
  width: min(var(--page-w), 100%);
  margin-inline: auto;
  padding-inline: var(--edge);
}

/* One landscape card, alternating sides of the page. The layout INSIDE is the
   same every time — copy left, phone right — so only the card's position
   zigzags, never its contents. */
.landing .slab {
  /* The phone's size, derived once: a fraction of the card's height, and the
     card's height is its width over its ratio. 2.169 is the screenshot's
     aspect ratio (1388 ÷ 640), which turns that height into a width. */
  --shot-h: calc(var(--slab-shot) * var(--slab-w) / var(--slab-ratio));
  --shot-w: calc(var(--shot-h) / 2.169);

  position: relative;
  /* clip, not hidden: a hidden box is still a programmatic scroll container,
     and anything that scrolls it (focus, fragment, scrollIntoView) shears the
     copy out of the card. clip cannot scroll. */
  overflow: clip;
  width: min(var(--slab-w), 100%);
  aspect-ratio: var(--slab-ratio);
  padding: var(--slab-pad);
  display: grid;
  /* The second column is EMPTY — the phone is positioned out of the flow (see
     below), and this only reserves its width. That is the whole trick: with
     the phone out of the grid, the single row is the copy's own height, so
     align-content positions the copy against the CARD rather than against a
     phone that may be taller than the card is. */
  grid-template-columns: 1fr var(--shot-w);
  align-content: var(--slab-copy-align);
  gap: var(--slab-gap);
  background: var(--card);
  border-radius: var(--slab-radius);
  box-shadow: var(--card-lift);
}

.landing .slab:nth-child(even) { justify-self: end; }

.landing .slab-copy { max-width: var(--slab-copy-w); }
.landing .slab-copy h2 { font-size: var(--t-slab); line-height: 1.2; margin-bottom: 0.7rem; }

/* The source glyphs floating in the video tile: the app's own monochrome
   marks, muted, scattered with a small tilt. Positions are inline on each
   glyph; left-half coordinates so they stay off the screenshot on desktop. */
.landing .slab-scatter { position: absolute; inset: 0; pointer-events: none; color: var(--faint); opacity: 0.55; }
.landing .slab-scatter svg { position: absolute; width: 1.6rem; height: 1.6rem; }
.landing .slab-copy p {
  color: var(--muted);
  font-size: clamp(1rem, 1.15vw, 1.15rem);
  line-height: 1.5;
  letter-spacing: normal;
}

/* Out of the flow and centred on the card itself, so a phone taller than the
   card overflows evenly top and bottom and .slab clips it.

   ⚠️ The centring is `top: 50%` + a -50% translate, NOT `place-items: center`.
   Grid clamps an item that overflows its area to the START edge, so a phone
   taller than the card hung off the bottom only and sat 55px low. Absolute
   positioning has no such rule. */
.landing .slab-art {
  position: absolute;
  /* Padded at the top, open at the bottom: the phone's top edge sits inside
     the card like everything else, and the overflow all goes off the foot,
     where .slab clips it. */
  inset-block: var(--slab-pad) 0;
  right: var(--slab-pad);
  width: var(--shot-w);
}

.landing .slab-art .shot {
  position: absolute;
  top: var(--shot-anchor);
  left: 0;
  height: var(--shot-h);
  width: auto;
  max-width: none;
  /* The percentage resolves against the phone's own height, so this pairs with
     --shot-anchor to give top / centre / bottom from a single token. */
  translate: 0 calc(-1 * var(--shot-anchor));
  border-radius: 1.4rem;
  box-shadow: var(--lift);
  transform: translateY(var(--slab-shot-y));
}

@media (max-width: 52rem) {
  .landing .slab,
  .landing .slab:nth-child(even) {
    width: 100%;
    justify-self: stretch;
    aspect-ratio: auto;
    grid-template-columns: 1fr;
    align-content: start;
  }

  .landing .slab-copy { max-width: none; }

  /* A window the phone hangs into, running past the bottom exactly as it does
     on desktop — the negative margin takes it to the card's edge and .slab
     clips it there. The window is a FRACTION of the phone's own height rather
     than a guessed rem, which is what keeps most of the phone on screen: at
     --shot-reveal 0.8 you see four fifths of it and the last fifth is the cut.
     The width has to be a viewport unit, not a percentage, or the height
     cannot be computed from it. */
  .landing .slab {
    --m-shot-w: min(57vw, 19.5rem);
    --shot-reveal: 0.8;
  }

  .landing .slab-art {
    position: relative;
    inset: auto;
    width: auto;
    height: calc(var(--shot-reveal) * var(--m-shot-w) * 2.169);
    margin: var(--slab-gap) 0 calc(-1 * var(--slab-pad));
  }

  .landing .slab-art .shot {
    top: 0;
    left: 50%;
    height: auto;
    width: var(--m-shot-w);
    translate: -50% 0;
  }

  /* ⚠️ The hero phone, and the thing that kept it small: --phone-size leans on
     18vw, which on a 390px screen is 70px — so the clamp fell to its 10rem
     floor and the phone was 160px wide on a phone. A viewport-WIDTH fraction
     is the right basis here, with the same leftover-height cap keeping the
     column inside one screen. */
  .landing {
    --phone-size: min(
      34vw,
      calc((var(--sh) - 4rem - var(--under-gap) - 14.5rem - var(--chunk)) / 2.169)
    );
    /* The bouquet rests in the MIDDLE of the screen (was 45%, which read as
       sitting high), and the reveal lifts the whole composition 9svh instead
       of desktop's 4vh, so the settled column lands at the same 41svh centre
       it had before and still clears the bottom of the screen. */
    --stage-y: 50%;
    --stage-drift: calc(-0.09 * var(--sh));
    /* Desktop's 7rem floats the chevron mid-air on a phone (VAD-597). svh, so
       it hangs off the height the visitor actually sees at the top of the
       page, where browser chrome is always expanded. */
    --hint-y: max(2.25rem, calc(0.04 * var(--sh)));
  }

  /* The settled column is phone + line + third line + badge + note in one
     screen, so on a phone everything under the phone sits tighter and the
     phone itself gives a little (46vw, was 50). Measured against a 773px
     screen (iPhone 15-class with browser chrome shown). */
  .landing { --under-gap: 1.6rem; }
  .landing .under { gap: 0.7rem; }
  /* A phone never shows the opening line or the chevron: the resting state
     is the bouquet alone, and the intro plays on its own. The line stays in
     the document for crawlers (it is the page's one h1). */
  .landing .headline-first,
  .landing .scroll-hint {
    position: absolute !important;
    width: 1px !important;
    height: 1px !important;
    overflow: hidden !important;
    clip-path: inset(50%) !important;
    padding: 0 !important;
    animation: none !important;
    pointer-events: none !important;
  }
  .landing .headline-second { font-size: 1.35rem; }
  .landing .headline-body { max-width: 100%; margin-bottom: 0; font-size: 0.85rem; line-height: 1.45; text-wrap: balance; }
  .landing .headline-second-wide { display: none !important; }
  .landing .headline-second-phone { display: block !important; }
  /* A smaller badge: the column was bottom-heavy. */
  .landing { --chunk: 2.9rem; }
  .landing .badge { gap: 0.5rem; padding: 0 1.1rem 0 0.9rem; }
  .landing .badge-text small { font-size: 0.62rem; }
  .landing .badge-text strong { font-size: 0.95rem; }

  /* The exploded pieces read too big on a phone; the resting bouquet is
     right. Shrinks only the explosion's end state (see --end-k). */
  /* Smaller and further out once exploded: on a phone the settled column is
     the whole width, so the pieces have to leave it, not sit behind the
     words. --spread multiplies the travel; 1 is the authored vector. */
  .landing .drift { --end-k: 0.62; --spread: 1.18; }

  /* The resting offsets were tuned against a ~13rem --cut; on a phone --cut
     sits at its 7rem floor, so the same pixel offsets read as a loose ring
     with a hole in it. Pack them down to match, like desktop's tight overlap. */
  .landing { --pack: 0.52; --sh: var(--vh-lock, 100svh); }

  /* On a phone the tile is a column, so the scattered source glyphs move to
     the gutters beside the screenshot. !important because the desktop spots
     are inline on each glyph. */
  .landing .slab-scatter svg:nth-child(1) { inset: 42% auto auto 5% !important; rotate: -8deg; }
  .landing .slab-scatter svg:nth-child(2) { inset: 38% 5% auto auto !important; rotate: 7deg; }
  .landing .slab-scatter svg:nth-child(3) { inset: auto auto 16% 6% !important; rotate: 6deg; }
  .landing .slab-scatter svg:nth-child(4) { inset: auto 6% 10% auto !important; rotate: -6deg; }

  /* Nothing on a phone is pinned to the viewport: the header scrolls with
     the page. iOS moves fixed elements with its collapsing toolbar, and that
     lagging jump was the last visible "jitter" on the recordings. */
  .landing .topbar { position: absolute; }

  /* One corner button on a phone: three dots opening a single menu with the
     languages, a rule, and Light / Dark. The separate appearance switch and
     the code + caret are hidden; the 44px coarse-pointer hit band stays. */
  .landing .mode,
  .landing .lang-code,
  .landing .lang-caret { display: none; }
  .landing .lang-dots,
  .landing .menu-sep,
  .landing .theme-item { display: block; }
  .landing .lang-btn { padding: 0.35rem; opacity: 0.85; }

  /* ⚠️ Four cutouts land ON the phone at this width, and no global multiplier
     fixes it. The phone is 56% of a narrow screen, so clearing it sideways
     needs travel a cutout heading sideways does not have — and multiplying a
     near-zero --dy does nothing for a piece heading right. These are per-piece
     mobile vectors, chosen to leave the phone's box by ~12px along whichever
     axis is cheapest for that piece. Re-derive them if --phone-size or
     --stage-y change here.

     !important is not laziness: the bouquet's vectors are INLINE styles on
     each img, and an inline custom property beats any stylesheet rule without
     it. The alternative is lifting all twenty pieces out of the markup, which
     buys nothing. */
  .landing .drift[src$="feta.webp"] { --dx: 16vw !important; --dy: calc(0.56 * var(--sh)) !important; }
  .landing .drift[src$="rosemary.webp"] { --dx: -38vw !important; --dy: calc(0.364 * var(--sh)) !important; }
  .landing .drift[src$="cilantro.webp"] { --dy: calc(-0.329 * var(--sh)) !important; }
  .landing .drift[src$="pasta.webp"] { --dx: -39.1vw !important; }

  /* Tighter everywhere on a phone — the desktop rhythm is far too airy here.
     --field-lead is the big one: half a desktop screen of hold before the
     cutouts start is most of a phone screen, and it sits ON TOP of whatever
     the deck's own margin says. Cutting it is what actually closes the gap. */
  /* No shadows on the pieces that animate. drop-shadow forces WebKit to
     re-rasterize the filtered layer while its transform is being scrubbed,
     and with ~60 animated cutouts that is the phone's whole frame budget:
     the low frame rate Joost recorded on 2026-08-21 (VAD-597). Flat cutouts
     on a phone, full shadows on desktop. */
  .landing .drift,
  .landing .field > img,
  .landing .wall img { filter: none; }

  .landing { --field-lead: calc(0.10 * var(--sh)); }
  .landing .deck { gap: clamp(2.5rem, 9vw, 4.5rem); margin-top: calc(-0.04 * var(--sh)); }
  .landing .field { padding-bottom: calc(0.02 * var(--sh)); }
  .landing .endnote { padding-block: clamp(3.5rem, calc(0.09 * var(--sh)), 6rem); }

  .landing .endnote-line { max-width: 18ch; }
}

/* Direct children ONLY. The deck lives inside .field too, and a descendant
   selector here turned every card screenshot into an absolutely positioned,
   rotated, cutout-sized cutout. */
.landing .field > img {
  position: absolute;
  /* Sized to land inside the range the exploded pieces end up at — they grow
     as they travel, so this is bigger than the bouquet's own clamp. The two
     populations have to look like one. */
  width: calc(var(--fs, 1) * clamp(7.65rem, 16.2vw, 14.4rem));
  max-width: none;
  translate: -50% -50%;
  rotate: var(--fr, 0deg);
  pointer-events: none;
  filter: drop-shadow(var(--cut-shadow));
  /* Same depth rule as the bouquet, off --fs instead of --s, so the two
     populations land on the same range of speeds. */
  --d: calc(1 + (var(--fs, 1) - 1) * 0.42);
}

/* --- Strays -----------------------------------------------------------
   Ingredients scattered down the flanks of the page below the hero. Each one
   rides its own view() timeline: it enters from the bottom already on screen,
   lags the scroll (parallax), and sheds a little rotation as it passes. They
   sit behind the content and never take pointer events; on narrow screens
   they'd sit ON the content, so they only exist from 64rem up. */
.landing .stray {
  display: none;
  position: absolute;
  z-index: 0;
  width: clamp(6rem, 9vw, 10rem);
  pointer-events: none;
}

@media (min-width: 64rem) {
  .landing .stray { display: block; }
}

/* Content rides above the strays. */
.landing .stack,
.landing .say h2,
.landing .endcap-copy { position: relative; z-index: 1; }
.landing .say, .landing .section { position: relative; }

/* --- Statements ---------------------------------------------------------- */
.landing .say { padding-block: var(--block); text-align: center; }
.landing .say h2 { max-width: 18ch; margin-inline: auto; }
.landing .ln { display: block; }
.landing .ln.soft { color: var(--muted); }

/* --- Cards --------------------------------------------------------------- */
.landing .stack { display: flex; flex-direction: column; gap: var(--gap); }
.landing .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); }

.landing .card {
  position: relative;
  background: var(--tile);
  border-radius: var(--radius);
  padding: 0.5rem;
  overflow: hidden;
}

.landing .card.small { display: flex; flex-direction: column; min-height: 23rem; }
.landing .card.small .art { flex: 1; }
.landing .card.small .line { padding: 4rem 1.5rem 1.5rem; }
.landing .card.wide {
  display: grid;
  grid-template-columns: 44% 1fr;
  align-items: center;
  min-height: 24.4rem;
}
.landing .card.wide .line { padding: clamp(1.25rem, 2.4vw, 3rem); }

.landing .feature {
  background: var(--tile);
  border-radius: var(--radius);
  overflow: hidden;
  display: grid;
  grid-template-columns: 27.75rem 1fr;
  align-items: center;
  min-height: 27rem;
}

.landing .feature.flip .art { order: 2; }
.landing .feature .line { padding: clamp(1.5rem, 2.4vw, 3rem); max-width: 20ch; }

.landing .art {
  position: relative;
  align-self: stretch;
  min-height: 13rem;
  overflow: hidden;
  display: grid;
  place-items: center;
}

.landing .art .shot {
  width: min(58%, 13rem);
  border-radius: 1.1rem;
  box-shadow: var(--lift);
  transform: translateY(12%);
}

.landing .art .illo { position: absolute; width: 42%; pointer-events: none; }
.landing .art .illo.solo { position: static; width: 46%; }

/* --- The value grid -----------------------------------------------------
   A card, in the deck, wearing exactly what the slabs wear — same ground, same
   radius, same shadow. It just spans the whole container rather than half of
   it, which is the only thing that distinguishes it. No rules between the
   cells (Joost, 2026-08-23): the card's own edge is the only line, and the
   cells' padding does the separating. */
.landing .props {
  background: var(--card);
  border-radius: var(--slab-radius);
  box-shadow: var(--card-lift);
  padding: clamp(1.25rem, 2.6vw, 2.5rem);
  overflow: hidden;
}

.landing .prop-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
}

.landing .prop {
  padding: clamp(0.9rem, 1.6vw, 1.4rem) clamp(1rem, 1.8vw, 1.6rem);
}

.landing .prop-icon {
  width: 1.6rem;
  height: 1.6rem;
  margin-bottom: 0.9rem;
  color: var(--faint);
}

.landing .prop h3 {
  margin: 0 0 0.45rem;
  font-size: clamp(1rem, 1.2vw, 1.2rem);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.3;
}

.landing .prop p {
  margin: 0;
  max-width: 30ch;
  color: var(--muted);
  font-size: clamp(0.9375rem, 1.05vw, 1.0625rem);
  line-height: 1.5;
}

@media (max-width: 60rem) {
  .landing .prop-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 38rem) {
  .landing .prop-grid { grid-template-columns: 1fr; }
}

/* --- The closing note ----------------------------------------------------
   The same line style and the same badge as the hero, on purpose: someone who
   read the whole page should not have to scroll back up for the button. */
.landing .endnote {
  position: relative;
  z-index: 4;
  display: grid;
  justify-items: center;
  gap: clamp(2rem, 4vw, 3rem);
  padding-block: clamp(4rem, calc(0.11 * var(--sh)), 8rem);
  padding-inline: var(--edge);
  text-align: center;
}

/* --- Footer --------------------------------------------------------------
   Deliberately plain, and the card colour so it reads as the page's floor. */
.landing .site-footer {
  position: relative;
  z-index: 4;
  background: var(--footer-bg);
  color: var(--footer-fg);
  padding-block: clamp(2rem, 4vw, 3rem);
}

/* A four-row grid with the two wrappers dissolved into it, so the name, the
   line and the copyright on the left share rows (and baselines) with
   Privacy, Support and Email on the right. The icon has row one to itself. */
.landing .site-footer .page {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: baseline;
  column-gap: 2rem;
}

.landing .site-footer .page > div,
.landing .site-footer nav { display: contents; }
.landing .footer-icon { grid-column: 1; grid-row: 1; }
.landing .footer-mark { grid-column: 1; grid-row: 2; }
.landing .footer-copy:nth-of-type(1) { grid-column: 1; grid-row: 3; }
.landing .footer-copy:nth-of-type(2) { grid-column: 1; grid-row: 4; }
.landing .site-footer nav a:nth-child(1) { grid-column: 2; grid-row: 2; }
.landing .site-footer nav a:nth-child(2) { grid-column: 2; grid-row: 3; }
.landing .site-footer nav a:nth-child(3) { grid-column: 2; grid-row: 4; }
.landing .site-footer nav a { margin-top: 0.35rem; text-align: right; }
.landing .site-footer nav a:nth-child(1) { margin-top: 0; }

/* The app's own icon, so the floor of the page carries the mark the visitor
   is about to look for on the App Store. */
.landing .footer-icon { width: 2.5rem; height: auto; margin-bottom: 0.85rem; }

.landing .footer-mark { display: inline-block; color: var(--footer-fg); font-weight: 600; font-size: var(--wordmark-size); letter-spacing: -0.01em; text-decoration: none; }
.landing .footer-copy { margin-top: 0.35rem; color: var(--footer-muted); font-size: 0.9375rem; }

/* One column on a phone. The nav becomes a left-aligned row (a right-aligned
   column wrapped under the identity block reads as a ragged accident) and
   the links get taller tap targets. */
@media (max-width: 52rem) {
  .landing .site-footer .page { display: block; }
  .landing .site-footer .page > div { display: block; }
  .landing .site-footer nav { display: flex; gap: 1.75rem; margin-top: 1.5rem; }
  .landing .site-footer nav a { margin-top: 0; padding-block: 0.3rem; text-align: left; }
}

.landing .site-footer a {
  color: var(--footer-muted);
  font-size: 0.9375rem;
  text-decoration: none;
}

.landing .site-footer a:hover,
.landing .site-footer a:focus-visible { color: var(--footer-fg); text-decoration: underline; }

/* --- 6 · Endcap ----------------------------------------------------------
   The copy and the badge sit above the pile rather than on it, so the pile is
   nobody's backdrop: it runs at full strength, overlapping and off-grid, and
   nothing has to be faded out to keep a headline legible. */
.landing .endcap {
  position: relative;
  padding-block: var(--block) 0;
  overflow: hidden;
  text-align: center;
}

.landing .wall {
  position: relative;
  height: clamp(24rem, 48vw, 38rem);
  margin-top: clamp(2.5rem, 6vw, 5rem);
  overflow: hidden;
  pointer-events: none;
}

/* Placed by hand, off any grid: each piece carries its own spot, tilt, size
   and drift rate. Rotation is the `rotate` property, not `transform`, because
   the bob owns transform. */
.landing .wall img {
  position: absolute;
  width: calc(var(--ws, 1) * clamp(4.5rem, 8.6vw, 8.5rem));
  max-width: none;
  translate: -50% -50%;
  rotate: var(--wr, 0deg);
  filter: drop-shadow(var(--cut-shadow));
}

.landing .endcap-copy {
  position: relative;
  z-index: 2;
  display: grid;
  justify-items: center;
  gap: 1.25rem;
}

.landing .note { color: var(--muted); }

@media (max-width: 60rem) {
  .landing .row2,
  .landing .card.wide,
  .landing .feature { grid-template-columns: 1fr; }
  .landing .feature.flip .art { order: 0; }
  .landing .card.wide,
  .landing .feature { min-height: 0; }
  .landing .card.wide .art,
  .landing .feature .art { min-height: 16rem; }
}

/* --- Motion --------------------------------------------------------------
   Layered on last, and only where the browser can drive animation from scroll
   position. Everything above already reads as a finished page without it. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* ⚠️ Every hero animation runs on scroll(root), the document's own scroll
       offset, with its range written as plain lengths. It used to run on view
       timelines named --well and --stage, which are the same thing for a
       stage at the top of the document EXCEPT that a view timeline is
       measured against the scrollport, and on a phone WebKit resolved that
       geometry differently for its GPU-accelerated animations (the phone's
       fade and scale) than for the rest: scrolling back up, the bouquet had
       closed while the phone was still there (VAD-597, 2026-08-22). A scroll
       timeline has no scrollport geometry to disagree about. Lengths mean
       "that many pixels of scroll", and scroll 0 is where the stage starts. */

    /* Phone and hero stacked in ONE grid cell rather than offset with a
       negative margin. Sticky duration is the containing block minus the
       element's MARGIN box, so a negative margin would silently give the phone
       a longer pin than the copy — which is exactly what it once did.
       Identical boxes, identical pins, and the two release as one. */
    .landing .well {
      height: var(--well-h);
      display: grid;
      grid-template-columns: 100%;
    }

    .landing .well > .phone-layer,
    .landing .well > .hero {
      grid-area: 1 / 1;
      align-self: start;
      position: sticky;
      top: 0;
      height: var(--sh);
    }

    .landing .phone-layer { display: block; z-index: 1; pointer-events: none; }
    .landing .hero { z-index: 5; }

    /* The cutout layer is sticky within the STAGE, which runs past the hero
       and through the first statement. So: pinned while the hero plays, still
       pinned while "Paste a link…" scrolls up underneath, and then — when the
       stage ends — handed to the page, scrolling away with the content while
       the pieces are still finishing their outward drift (the range runs to
       exit 170%). One continuous trajectory, no hard handoff. */
    .landing .cluster-layer {
      display: block;
      position: sticky;
      top: 0;
      height: var(--sh);
      margin-bottom: calc(-1 * var(--sh));
      z-index: 3;
      pointer-events: none;
    }

    /* The release ramp — the whole reason the handoff used to feel like a
       brick wall.

       While the layer is pinned its pieces move at the explosion's speed,
       which is nothing like the page's. The frame it unpins, the page's full
       scroll speed is added in one go. Position stays continuous, velocity
       does not, and a velocity step is exactly what reads as "janky".

       So spend the last --ramp of the pin arriving at the page's speed. If
       the layer travels up by half the ramp length over the ramp, following a
       parabola, its speed rises linearly from 0 to precisely 1× scroll — and
       at the release it is already moving at the same rate the page is about
       to move it. Nothing changes at the boundary; the pin dissolves.

       cubic-bezier(1/3, 0, 2/3, 1/3) is not an approximation: those control
       points give y = x² exactly, so the speed ramp really is linear.

       ⚠️ The range has to end at the REAL release, and that is `exit 100%`,
       not `contain 100%`. The layer's margin box is zero (see the negative
       margin above), so sticky lets it hold until the stage's bottom edge
       passes the top of the screen — a whole screen further than the point
       where the stage stops covering the scrollport. Ramping over `contain`
       finishes one viewport early and puts the wall straight back.

       The exit phase is exactly the right window, and it is always one
       viewport long. It also has to be the WHOLE window, which is why the
       stage is nothing but the well: the cutouts must never stand still once
       the hero has let go. Give the stage anything extra below the well and
       that extra becomes a stretch where they are pinned and motionless while
       the page moves past them — which is the thing that read as two separate
       planes sliding over each other. Now the moment the hero releases is the
       moment they start moving, they accelerate for one screen, and they are
       at the field's exact speed before they meet it. */
    /* ⚠️ The boundaries are LENGTHS from the contain start (scroll 0 — the
       stage is first in the document), not `exit` percentages. An exit range
       is measured against the live scrollport, and on a phone that height
       changes every time the browser chrome hides or shows — which made the
       whole layer jump at every scroll direction change (VAD-597). These are
       the same two scroll positions, written in units that cannot move:
       the release is at one full stage height of scroll (the old `exit 100%`,
       where the zero-margin box lets the stage's bottom pass the top of the
       screen), and the ramp starts --ramp before it. */
    .landing .cluster-layer {
      animation: vad-release cubic-bezier(0.3333, 0, 0.6667, 0.3333) both;
      animation-timeline: scroll(root);
      animation-range: calc(var(--well-h) - var(--ramp)) var(--well-h);
    }

    /* Slow to start, then away — an ease-in, so the bouquet holds together
       for the first turn of the wheel before it comes apart. Driven by the
       stage, so the cutouts keep travelling after the headline has gone. */
    /* Driven by the WELL, not the stage, so the centre is clear by the time
       the headline releases. The 64% keyframe sits on that release, and the
       last third of the travel happens afterwards. */
    /* Two animations, two timelines, two ranges — the explosion on
       translate/rotate/scale during the reveal, and the parallax on transform
       from the release onward. They never overlap in time OR in property. */
    .landing .drift {
      animation-name: vad-explode, vad-depth-released;
      animation-fill-mode: both, both;
      animation-timing-function: var(--explode-ease), linear;
      animation-timeline: scroll(root), scroll(root);
      /* Ends WITH the reveal, not a screen and a half later. It used to run to
         `exit 170%`, which meant that at the settled moment the pieces were
         only ~46% of the way out and half of them were still lying across the
         phone. Now the bouquet is fully open before the dwell begins, and what
         happens after the dwell is the release ramp moving the whole layer —
         not the explosion still creeping.

         The parallax picks up at `exit 100%`, which is the release, so a piece
         is at a flat 1× the moment sticky lets go and only then starts pulling
         ahead of or behind the page. */
      /* The parallax boundaries are lengths for the same reason as the release
         ramp above: `exit N%` re-measures against the live scrollport and
         jumps when mobile browser chrome hides or shows. 400svh matches the
         keyframe's travel exactly. */
      animation-range: 0px var(--reveal), var(--well-h) calc(var(--well-h) + 400svh);
    }

    /* The crossfade: the opening line leaves while the bouquet is still coming
       apart, and the second lands as the phone finishes arriving. */
    .landing .headline-first {
      animation: vad-headline-out linear both;
      animation-timeline: scroll(root);
      animation-range: 0px calc(0.05 * var(--reveal));
    }

    /* The chevron's exit is opacity ONLY, and longer than the headline's.
       It used to share vad-headline-out over the first 5% of the reveal, which
       scrubbed a 4vh counter-translate across ~20px of scroll, so every
       micro-scroll near the top shook it (VAD-597). A fade cannot shake, and
       the wider range keeps it from flickering on tiny scroll adjustments. */
    .landing .scroll-hint {
      animation: vad-hint-out linear both;
      animation-timeline: scroll(root);
      animation-range: 0px calc(0.25 * var(--reveal));
    }

    /* The nudge lives on the svg so the rule above can own transform. */
    .landing .scroll-hint svg {
      animation: vad-nudge 1.9s ease-in-out infinite alternate;
    }

    .landing .headline-body { display: block; }

    .landing .headline-second {
      display: block;
      animation: vad-headline-in linear both;
      animation-timeline: scroll(root);
      animation-range: calc(0.55 * var(--reveal)) calc(0.86 * var(--reveal));
    }

    /* Late and short. The phone has no entrance of its own — the centre
       clears, and then it is simply there. */
    .landing .hero-phone {
      animation: vad-uncover cubic-bezier(0.2, 0.65, 0.2, 1) both;
      animation-timeline: scroll(root);
      animation-range: 0px calc(0.43 * var(--reveal));
    }

    .landing .hero .cta-row,
    .landing .headline-body {
      animation: vad-fade-up cubic-bezier(0.2, 0.65, 0.2, 1) both;
      animation-timeline: scroll(root);
      animation-range: calc(0.74 * var(--reveal)) var(--reveal);
    }

    /* Keep the scrubbed pieces on their own compositor layers for the whole
       session instead of letting the browser promote and demote them as
       their ranges come and go. Scoped to the motion block on purpose: a
       page without scroll timelines has nothing to promote. */
    .landing .drift,
    .landing .field > img {
      will-change: transform, translate, rotate, scale;
    }

    /* Reveals below the hero run off each element's own view timeline. */
    .landing .reveal,
    .landing .say .ln {
      animation: vad-fade-up linear both;
      animation-timeline: view();
      animation-range: entry 14% entry 88%;
    }

    .landing .say .ln:nth-child(2) { animation-range: entry 26% entry 96%; }

    .landing .stray {
      animation: vad-stray linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }

    /* Same depth model as the bouquet, read off --fs instead of --s. This is
       a deliberate reversal of the field's original "no animation at all"
       rule: it moved at exactly 1x so it could match the released cutouts
       exactly. Now BOTH sides have depth, and matching is per-depth — a field
       piece at d and a cutout at d still move together. What must not happen
       is one side having depth and the other not. */
    .landing .field > img {
      animation: vad-depth-field linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }

    /* Every piece on its own clock — the negative delays start them
       mid-stroke, so nothing in the pile ever moves in unison. */
    .landing .wall img {
      animation: vad-bob var(--wd, 12s) ease-in-out infinite alternate;
      animation-delay: var(--wdelay, 0s);
    }
  }
}

/* --- The intro on a phone: played once, not scrubbed ----------------------
   The SAME choreography as the scroll-driven hero above (same keyframes, same
   order, same lift) run as a timed sequence: the bouquet opens, the phone
   comes out of it, the lines cross-fade, the badge lands. Triggered by the
   script: first scroll, a tap on the chevron, or ~1.8s after load, whichever
   comes first. Until then every animation sits paused on its first frame,
   which is the resting bouquet.

   ⚠️ Why this exists (VAD-597, 2026-08-22, three screen recordings): on iOS
   the collapsing browser toolbar moves the visual viewport, so a sticky,
   pinned hero jumps with it, and WebKit's scroll-linked animations were seen
   frozen mid-state for whole frames. No timeline, unit or library changes
   how iOS moves the viewport. A timed animation on the compositor, on an
   in-flow block that scrolls with the page, has none of those inputs.

   Nothing on a phone is scroll-linked any more: the well is one screen tall
   so the sticky layers never pin, the release ramp and both parallaxes are
   off, and the field is still. Desktop is untouched. */
@supports (animation-timeline: view()) {
  @media (max-width: 52rem) and (prefers-reduced-motion: no-preference) {
    .landing {
      --well-h: var(--sh);
      --intro-t: 1.3s;
      --intro-ease: var(--explode-ease);
    }

    /* The stack rises with the explosion, on the same clock. */
    .landing .cluster,
    .landing .phone-layer,
    .landing .under {
      animation: vad-stage-rise var(--intro-t) var(--intro-ease) both paused;
    }

    /* No release ramp: the layer never pins, so there is nothing to hand off. */
    .landing .cluster-layer { animation: none; }

    /* Explode only, no post-release parallax. */
    .landing .drift {
      animation: vad-explode var(--intro-t) var(--intro-ease) both paused;
    }

    .landing .headline-first {
      animation: vad-headline-out 0.35s ease-out both paused;
    }

    .landing .scroll-hint {
      animation: vad-hint-out 0.35s ease-out both paused;
    }

    .landing .headline-second {
      /* Less travel and more time than the scrubbed version: a timed entrance
         reads as a pop at 0.5s/28px, so 0.7s and under 1rem. */
      --h2-y-from: 0.9rem;
      animation: vad-headline-in 0.7s cubic-bezier(0.2, 0.65, 0.2, 1) 0.6s both paused;
    }

    .landing .hero-phone {
      animation: vad-uncover 0.7s cubic-bezier(0.2, 0.65, 0.2, 1) 0.15s both paused;
    }

    .landing .headline-body {
      animation: vad-fade-up-soft 0.7s cubic-bezier(0.2, 0.65, 0.2, 1) 0.75s both paused;
    }

    .landing .hero .cta-row {
      animation: vad-fade-up-soft 0.7s cubic-bezier(0.2, 0.65, 0.2, 1) 0.9s both paused;
    }

    .landing .stage.play .cluster,
    .landing .stage.play .phone-layer,
    .landing .stage.play .under,
    .landing .stage.play .drift,
    .landing .stage.play .headline-first,
    .landing .stage.play .scroll-hint,
    .landing .stage.play .headline-second,
    .landing .stage.play .hero-phone,
    .landing .stage.play .hero .cta-row,
    .landing .stage.play .headline-body {
      animation-play-state: running;
    }

    /* The field sits still on a phone. */
    .landing .field > img { animation: none; }

    /* The bob on the chevron is a time animation already and can stay; it
       leaves with the chevron. */
  }
}

/* 64% of this range lands on the frame the hero unpins, so by then the cutouts
   are 88% of the way out and the middle of the screen belongs to the phone.
   The remainder is the drift that carries on afterwards. */
@keyframes vad-explode {
  /* 1.05, not 0.9 — the bouquet at rest is what people see first, and it was
     reading small. Only the start moves; where the pieces end up is the
     authored state on .drift and is unchanged. */
  /* --pack scales the RESTING offsets: --ox/--oy are pixel values tuned on a
     desktop-sized --cut, and on a phone the pieces are half that size, so
     unscaled offsets scatter the small pieces into a loose ring. The travel
     (--dx/--dy) is deliberately not packed. */
  from {
    translate: calc(-50% + var(--ox) * var(--pack, 1)) calc(-50% + var(--oy) * var(--pack, 1));
    rotate: 0deg;
    scale: 1.05;
  }
  to {
    translate:
      calc(-50% + var(--ox) * var(--pack, 1) + var(--dx) * var(--k) * var(--spread, 1))
      calc(-50% + var(--oy) * var(--pack, 1) + var(--dy) * var(--k) * var(--spread, 1));
    rotate: var(--r);
    scale: calc((0.9 + 0.55 * var(--k)) * var(--end-k, 1));
  }
}

/* Parallax, both populations. A piece at depth d covers d× the page's
   distance; the released cutouts pick it up the moment sticky lets go, and the
   field carries it the whole way down. 400svh is the span of the range below,
   and 62svh is half a `cover` range (one viewport plus roughly one cutout), so
   in both cases the piece ends up having moved exactly (d − 1) further than
   the page did. */
@keyframes vad-depth-released {
  from { transform: none; }
  to { transform: translateY(calc((1 - var(--d)) * 400svh)); }
}

@keyframes vad-depth-field {
  from { transform: translateY(calc((var(--d) - 1) * 62svh)); }
  to { transform: translateY(calc((1 - var(--d)) * 62svh)); }
}

/* Not an entrance — the phone is already there, the bouquet is just in front
   of it. It only firms up as the cutouts clear. */
/* The start scale drops as --phone-size grows, on purpose: the phone should
   arrive a little bigger than it used to and END a lot bigger. 0.42 of the new
   size is roughly what 0.51 of the old one was. */
@keyframes vad-uncover {
  from { opacity: 0; transform: scale(0.42); }
  to { opacity: 1; transform: scale(1); }
}


/* Half the ramp length, because the average speed over a linear ramp from 0
   to 1× is ½×. Change this and the layer arrives at the wrong speed. */
@keyframes vad-release {
  from { transform: none; }
  to { transform: translateY(calc(-0.5 * var(--ramp))); }
}

@keyframes vad-stage-rise {
  from { transform: none; }
  to { transform: translateY(var(--stage-drift)); }
}

@keyframes vad-stray {
  from { transform: translateY(9vh) rotate(var(--sr, 0deg)); }
  to { transform: translateY(-9vh) rotate(calc(-1 * var(--sr, 0deg))); }
}

@keyframes vad-headline-out {
  from { opacity: 1; transform: translateY(var(--h1-y-from)); }
  to { opacity: 0; transform: translateY(var(--h1-y-to)); }
}

/* No transform on purpose: the chevron's scrub must not move it (see the
   rule that uses this). The bob on the svg is the only motion it has. */
@keyframes vad-hint-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

@keyframes vad-headline-in {
  from { opacity: 0; visibility: hidden; transform: translateY(var(--h2-y-from)); }
  to { opacity: 1; visibility: visible; transform: translateY(var(--h2-y-to)); }
}

@keyframes vad-nudge {
  from { transform: translateY(-3px); }
  to { transform: translateY(3px); }
}

/* visibility, not just opacity: opacity 0 still takes clicks, so a badge that
   has not arrived yet is an invisible link sitting in the middle of the hero.
   Discrete properties flip at the halfway point of the range, which is the
   right moment — by then it is half visible. */
@keyframes vad-fade-up {
  from { opacity: 0; visibility: hidden; transform: translateY(1.375rem); }
  to { opacity: 1; visibility: visible; transform: none; }
}

/* The timed intro's entrance: the scrubbed vad-fade-up travels 1.375rem,
   which reads as a pop when run on a clock. Softer for the phone. */
@keyframes vad-fade-up-soft {
  from { opacity: 0; visibility: hidden; transform: translateY(0.75rem); }
  to { opacity: 1; visibility: visible; transform: none; }
}

@keyframes vad-bob {
  from { transform: translateY(-10px); }
  to { transform: translateY(10px); }
}
