/* Thessa's card art, landed 2026-09-28. The backs are her three colourways
   (back-green / back-amber / back-rose in img/), built from the print PDF by
   store/projects/universe-cards/scripts/build-card-art.sh. Re-run that script
   rather than re-exporting by hand: it asserts the print geometry before it
   writes anything.
   The cards are 70 x 120 mm in print, so aspect-ratio is 70 / 120 here and not
   the 2 / 3 it was while the art was a gradient. Both .card and .deck carry it
   and they have to agree, the deal measures one against the other.
   Each slot also sets --card-paper / --card-ink, so a turned card shows the same
   paper as the back it flipped from instead of one shared cool white. */

:root {
  --ink: #0b1020;
  --paper: #eef1f7;
  /* A card in the row is capped by the VIEWPORT HEIGHT as well as by 200px, because a
     turned card grows into the height (see .card.open) and on a phone held sideways
     there is barely any. Without the height term the row card was 200 x 343 in a
     390px-tall window and the "enlarged" one came out at 195 x 334: the flip made the
     card SMALLER than it had been, and it landed on top of the middle slot so it read
     as hopping sideways rather than coming forward. .55 keeps the growth at roughly
     1.8x wherever the height bites, and in portrait and on a desktop the 200px term
     still wins so nothing changes there. The vh line is the fallback for no dvh. */
  --font-caps: "Deck Caps", ui-rounded, "SF Pro Rounded", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  /* The message on a turned card. Cardo Bold, self-hosted, see the @font-face below. */
  --font-serif: "Card Serif", Cardo, Georgia, "Times New Roman", serif;
  --card-max: min(200px, calc((100vh - 56px) * 7 / 12 * .55));
  --card-max: min(200px, calc((100dvh - 56px) * 7 / 12 * .55));
  --spread-gap: clamp(16px, 4vw, 40px);
  --radius: 10px;

  /* How tall the tray under an enlarged card is. It is a fixed figure rather than a
     measurement because the card's own size is derived FROM it: the card has to know
     how much height the tray will take before either of them is laid out, and a
     measured value would need a second pass and a reflow in the middle of the flip. */
  --tray-h: 110px;
  /* Its twin for a phone on its side, where the tray moves BESIDE the card and takes
     width out of the viewport instead of height. See the landscape block below. */
  --tray-w: 236px;

  /* The two widths every size on this page is derived from, written once.
     Row: three cards plus two gaps inside #stage's 8px padding, capped by --card-max.
     Open: 84vw, never past 400px, and never taller than the space left over the tray.
     Both are real lengths, so font sizes can be a fraction of them and the type tracks
     the card exactly instead of approximating it off the viewport. */
  --card-w-row: min(var(--card-max), calc((100vw - 16px - 2 * var(--spread-gap)) / 3));

  /* How much height the wordmark takes off the top of the window: the stage's own top
     padding, the mark's height (its width over the SVG's aspect ratio, 77.138/27.385 =
     1.667) and a small gap under it. Written out rather than measured because the open
     card's SIZE is derived from it and a measured value would need a second pass.
     Keep this in step with #stage's padding and #brand's width. */
  --header-h: calc(max(20px, env(safe-area-inset-top)) + clamp(152px, 46.4vw, 224px) / 1.667 + 12px);

  /* The band a turned card lives in: everything between the bottom of the wordmark and
     the top of the tray. Joost, 2026-09-29, with a screenshot: "Kaart staat net over
     logo. Ofwel helemaal over logo of helemaal logo tonen (oftwel kaart iets kleiner)."
     Half-over was the fault, so the card is now sized and centred so it cannot reach
     the mark at all, rather than being given a viewport fraction that happened to clear
     it on the phone it was measured on. The 68vw that used to be the first term is gone
     with the same reasoning: the band is the real constraint, so the card is as large as
     the space below the logo allows and no larger. */
  --open-band: calc(100vh - var(--header-h) - var(--tray-h) - 16px);
  --open-band: calc(100dvh - var(--header-h) - var(--tray-h) - 16px);
  --card-w-open: min(86vw, 400px, calc(var(--open-band) * 7 / 12));

  /* Where the middle of a turned card sits. The middle of the band, NOT the middle of
     the window: the window's middle is what put the card over the wordmark. The tray
     below it is positioned off this same value, so the two cannot drift apart. */
  --open-cy: calc(var(--header-h) + var(--open-band) / 2 + 8px);
}

/* ---- day and night ----
   Joost, 2026-09-29: "kun je ook een day & night versie maken (achtergrond dark blue)
   met sterretjes in de kleuren van de cards (light) en day versie in de paper beige met
   sterretjes in de kleur van de kaarten ook maar dan de donkerdere varianten".
   Which one shows is decided by the SCANNER'S OWN CLOCK, see the inline script in
   index.html. Everything that differs between the two lives in these two blocks and
   nothing below this point names a background colour directly.
   data-theme is on <html> and is set synchronously in the head, before first paint, so
   the page never flashes the wrong sky. */
html[data-theme="night"] {
  --bg: #0b1020;
  --bg-glow: #1a2445;
  --sky: url("img/sky-night.svg?v=43bd2b4dcb");
  --sky-alpha: .85;
  /* Joost, 2026-09-29: "op dark blue bg allemaal lightblue". Every word on the page
     background is now the wordmark's own ink, so the page reads as one object instead
     of a logo sitting on somebody else's text. Opacities are untouched: the dimmed
     lines stay dimmed, they are just dimmed in this colour now. */
  --on-bg: #cee1e8;
  /* The wordmark's ink. Joost, 2026-09-29: "Logo mag in lichtblauw op donkerblauwe bg
     en oranje op de beige bg." So each sky gets the OTHER deck's paper as its ink: the
     blue card's own paper on the navy, and the amber card's ink on the beige. It was
     her printed amber on both until then, which on the navy was the loudest thing on
     a page whose whole job is to be quiet. */
  --brand-ink: #cee1e8;
}
html[data-theme="day"] {
  /* The beige is the amber card's own paper, so the page reads as the same stock the
     cards are printed on rather than as a second cream picked nearby. */
  --bg: #f2e8d2;
  --bg-glow: #fdf7ea;
  --sky: url("img/sky-day.svg?v=b6509e276c");
  --sky-alpha: 1;
  /* "Tekst op beige staat allemaal oranje". Same move on the day theme, one shade
     down from the wordmark's #b8690f: that orange reads 3.4 : 1 on this beige, which
     is fine for a 200px logo and not fine for a 10px letterspaced label. This is the
     same hue at 4.8 : 1. Side by side at logo size you would see the difference; at
     body size you cannot, and the small text is where it matters. */
  --on-bg: #96540b;
  /* One step down from the night amber. The printed orange on her beige paper is the
     same value as the paper and the mark goes flat; this is the darker card ink, the
     same one the day starfield uses. */
  --brand-ink: #b8690f;
}

/* The deck's own lettering. The printed cards set their message in a condensed
   grotesque caps, and Joost asked on 2026-09-29 for the site to read the same way
   (everything but the headline, which is set in the card's own Cardo Bold).
   STAND-IN: this is Oswald 400, the closest free match to the print out of the three
   I put in front of him. Swapping it is one file copy plus `npm run stamp`, which
   re-hashes the ?v= below so returning visitors get the new face instead of the cached
   old one, and nothing else.
   font-display: swap so the page never waits on it, and the system stack behind it is
   the same one the whole site used before, so a failed font load degrades to the old
   look rather than to Times. */
/* The message on a turned card. Joost, 2026-09-29: "de achterkant van de kaartjes na
   payment moet fontje Bright or Cardo bold". Bright is a commercial face nobody here
   has a licence for; Cardo is SIL OFL, so this is Cardo Bold, latin subset, served from
   our own origin rather than from Google so the page makes no third-party font request.
   19 KB. If Bright is ever bought, drop the woff2 in beside this one under the same
   name and run `npm run stamp`, nothing else changes. */
@font-face {
  font-family: "Card Serif";
  src: url("assets/fonts/card-serif.woff2?v=15e403f0e2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Deck Caps";
  src: url("assets/fonts/deck-caps.woff2?v=e902b779e4") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

/* iOS text autosizing OFF. This is the one line that fixes the card lying in the row.
   Every size on a card is a fraction of the card's own width (see .card .text), which
   is about 6px in the row and about 14px enlarged, and the two are the same picture at
   two scales. Safari does not honour that: it inflates small text inside a block it
   judges to be a column of reading, so the row card came out at roughly 10px, wrapped
   to more lines than it had room for and cut the message off mid-sentence. Joost,
   2026-09-29, with a screenshot: "Staat van gekozen kaart verandert als hij open op
   'tafel' ligt, dat mag niet. Moet hetzelfde eruitzien als in de schermvullende
   weergave." It is invisible on a desktop and in headless Chromium, so the browser
   suite cannot catch a regression here: check it on a real phone. */
html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

html, body {
  margin: 0;
  min-height: 100%;
  /* width, not just min-height. The root element is a flex container here, which makes
     body a flex ITEM, and a flex item is shrink-to-fit. During the intro the three
     slots are `flex: 1 1 0; min-width: 0` holding cards that are `visibility: hidden`,
     so the spread's max-content width is two gaps and nothing else: body collapsed to
     242px of a 390px phone and .spread to 226 instead of 374. The deck is a percentage
     of .spread, so the shuffling stack came out 65px wide and then the dealt cards
     landed at 114. Joost, 2026-09-29: "Bug in animatie, kaarten zijn klein bij
     shuffle." One line, because the deck's size was never wrong, the box it measured
     itself against was. */
  width: 100%;
  background:
    radial-gradient(1200px 700px at 50% -10%, var(--bg-glow) 0%, transparent 60%),
    var(--bg);
  color: var(--on-bg);
  font: 400 16px/1.5 ui-rounded, "SF Pro Rounded", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* The sky. One seamless 900px tile per theme, built by scripts/build-sky.mjs, repeated
   rather than stretched so it covers a phone in landscape and a 1440 desktop off the
   same 17 KB file. It was six hand-placed radial-gradient specks before 2026-09-29.
   background-attachment: fixed keeps it still while a long message scrolls, which is
   what makes it read as a sky the cards are lying under rather than as wallpaper glued
   to the page. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background-image: var(--sky);
  background-repeat: repeat;
  background-size: 900px 900px;
  opacity: var(--sky-alpha);
}

/* Joost, 2026-09-29: the wordmark belongs "bovenaan, hoog op de pagina", so the header
   is no longer part of a vertically centred stack. It sits at the top padding, and the
   spread, the price and the fine print centre themselves in the space that is left: the
   auto margin at each end of that group splits the free height evenly between them.
   flex-start rather than space-between, which would push the price to the very bottom of
   a desktop window and leave it stranded a long way under the cards. */
#stage {
  position: relative;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 22px;
  padding: max(20px, env(safe-area-inset-top)) 8px max(20px, env(safe-area-inset-bottom));
  text-align: center;
}

/* The other end of that centring. An empty last flex item whose auto top margin shares
   the free height with .spread's auto, which lands the spread and the price in the
   middle of whatever is left below the header. It has to be a pseudo-element and not
   .fineprint, because .fineprint carries display: none while it is empty and an item
   that is not there cannot hold a margin: the group would sink to the bottom of the
   window on every visit that has nothing to say in the fine print, which is most. */
#stage::after {
  content: "";
  margin-top: auto;
}

.intro {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
}

/* The wordmark, at the top of the page. Joost, 2026-09-29 (voice): "Alleen het logo van
   Message from the Universe. Die mag wel even flink groter. En ook wel bovenaan, hoog op
   de pagina." The full lockup used to sit here, emblem over wordmark, drawn 90px tall,
   which left the words themselves about 50px wide. This is the words alone at four times
   that width, and the emblem now lives where he asked for it: small, along the bottom
   edge of a turned card.

   A MASK, not an <img>, and one file rather than three. The mark is a single vector cut
   from her printed card (scripts/build-wordmark.mjs), so the ink is whatever this rule
   paints behind it, which lets the night and day themes each take their own amber
   without a second export. Mind the order: -webkit-mask has to stay, Safari still wants
   it, and a mask with no fallback means a browser that supports neither shows nothing,
   which is why the element carries role=img and a label rather than being decorative.

   Sized by WIDTH, not height, because the wordmark is a block of lines: at 58vw it is
   a header on a phone and it stops growing on a desktop. The aspect ratio is the SVG's
   own viewBox, written out so the box is the right shape before the file has loaded and
   the cards below it do not jump when it arrives. */
#brand {
  /* 20% off every term on 2026-09-29, at Joost's measurement, not mine: "20% kleiner.
     Staat wel op de goede plek verder". 190/58/280 -> 152/46.4/224. */
  /* Three lines since 2026-10-01, so the same WIDTH is now 1.7x as tall and it was
     eating the band the cards live in. Width pulled back from 152/46.4vw/224 to the
     figures below, which land the mark at the same HEIGHT on the page it had as a two
     line lockup. Joost measured the old one himself ("20% kleiner"), and what he was
     measuring was how much room it took, not how wide it was.
     Keep this ratio in step with what scripts/build-wordmark.mjs prints. */
  width: clamp(90px, 27.5vw, 133px);
  aspect-ratio: 58.48 / 35.083;
  background: var(--brand-ink);
  -webkit-mask: url("img/wordmark.svg?v=94981bf1da") center / contain no-repeat;
  mask: url("img/wordmark.svg?v=94981bf1da") center / contain no-repeat;
}

/* Deck Caps at 400, the face the tray buttons are set in. Joost asked for Cardo Bold
   earlier the same day ("font zelfde als font van achterkant cards"), saw it, then
   asked for this instead: "Choose your card titel mag in deck 400". A step through
   Oswald 600 and 500 got skipped with it, so nothing new had to be subsetted: 400 is
   already on disk for the buttons.
   Set in caps with the letters opened up, because the file is a condensed face and
   condensed lowercase at 27px on a phone closes up into a stripe.
   Badhorse is NOT queued for this any more. If it comes back it is Joost's call, and it
   wants an @font-face next to the Deck Caps one plus the name at the front of
   --font-caps. There is deliberately no @font-face for it while the file is not on
   disk: one declared against a missing file costs every visitor a 404 and a render
   stall for nothing.
   margin-top: auto is the top end of the centred group, moved here from .spread the
   same day: the headline is a caption for the cards, so it has to travel with them
   rather than sit in the header. */
h1 {
  margin: auto 0 0;
  /* The wordmark's ink, and so is everything else on the page background. Joost,
     2026-09-29: "gebruik voor knoppen en titels zelfde kleur als logo". */
  color: var(--on-bg);
  font-family: var(--font-caps);
  font-size: clamp(20px, 5.4vw, 27px);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: .06em;
}

/* ---- the three cards ---- */

/* The row fills the width it is given and the cards grow into it, so a card is as
   large as three across can be rather than a fixed 132px. --card-max is what stops
   them on a desktop.
   margin-top: auto is half of the centring described at #stage: it and the auto on
   #stage::after are the two ends of the group that shares the height left below the
   header. */
.spread {
  /* The auto top margin moved onto h1 above, which now leads this group. */
  position: relative;
  /* position + z-index makes this a stacking context, so the open card's z-index:40
     only ranks INSIDE here. The price line is a later sibling in the DOM and was
     painting straight through the turned card's paper. One step up puts the whole
     spread over it, and the payment sheet at z-index 60 still wins over both. Found
     2026-09-28. This used to come free with the perspective property; when that moved
     onto the cards (see below) the stacking context had to be stated on purpose. */
  z-index: 1;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  gap: var(--spread-gap);
  width: 100%;
  max-width: calc(3 * var(--card-max) + 2 * var(--spread-gap));
  /* The perspective USED to live here, as the `perspective` property, and that made
     .spread the containing block for everything fixed inside it. So `top: 50%` on the
     open card and on the tray meant half of .spread, not half of the window. In portrait
     .spread happens to sit near the middle of the screen and nobody noticed. In landscape
     it sits at 206 of 390, so the card was centred on 298 and hung 87px off the bottom
     edge. The perspective is now a perspective() FUNCTION in each card's own transform
     (see .card.revealed / .card.open), which creates no containing block, so fixed means
     fixed to the window, which is what every position in this file already assumed.
     Side effect, and an improvement: each card now flips around its own vanishing point
     instead of the spread's, so the outer two no longer skew toward the middle.
     Found 2026-09-29. */
}

.slot {
  flex: 1 1 0;
  min-width: 0;
  max-width: var(--card-max);
  display: flex;
  flex-direction: column;
  align-items: center;
}

.card {
  width: 100%;
  aspect-ratio: 70 / 120;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  position: relative;
  transform-style: preserve-3d;
  transition: transform .6s cubic-bezier(.2, .7, .2, 1);
}

/* pointer-events: none matters. These are decoration, but a pseudo-element hit-tests
   AS ITS PARENT, so without this the paper sits over the share and continue buttons and
   every tap on them lands on the card instead. Caught by test/browser.mjs, 2026-09-28.
   The card itself still takes taps: it has its own box. */
.card::before,
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: var(--radius);
  backface-visibility: hidden;
  box-shadow: 0 10px 26px rgba(0, 0, 0, .45);
}

/* back. --card-back is set per slot below, so the three cards in the spread are
   the three different colourways rather than one design printed three times. */
.card::before {
  background-image: var(--card-back);
  background-size: cover;
  background-position: center;
  background-color: #1b2440;
}

/* face, revealed. Her own back art with the moon emblem erased, so a turned card is
   visibly the same physical object: same paper, same grain, same frame and corner
   ornaments. The flat paper colour stays underneath as the fallback, which is what
   shows for the half second before the webp arrives. */
.card::after {
  background: var(--card-paper, var(--paper)) var(--card-face) center / cover no-repeat;
  transform: rotateY(180deg);
}

/* Sampled out of the art itself, not picked by eye. The ink is a darkened form of
   each card's accent so the message clears AA on its own paper: the amber and rose
   as printed sit near 2:1 against their cream and are unreadable at 9px.
   Slot 0 was GREEN until 2026-09-29, when Joost asked for it in light blue matching the
   page background. It is her green card rotated 130 degrees in hue, which lands the ink
   on the site's own #1a2445, see scripts/build-card-art.sh. back-green / face-green /
   logo-green are still on disk and nothing points at them. */
.card[data-slot="0"] {
  --card-back: url("img/back-blue.webp?v=9ef95612ab");
  --card-face: url("img/face-blue.webp?v=9995a2b541");
  --card-emblem: url("img/emblem-blue.webp?v=82061c294a");
  --card-paper: #cee1e8;
  --card-ink: #24346c;
}
.card[data-slot="1"] {
  --card-back: url("img/back-amber.webp?v=1cfb426811");
  --card-face: url("img/face-amber.webp?v=e0ce6e7200");
  --card-emblem: url("img/emblem-amber.webp?v=09e8c4c7bc");
  --card-paper: #f2e8d2;
  --card-ink: #8a4b12;
}
.card[data-slot="2"] {
  --card-back: url("img/back-rose.webp?v=0af2db84d3");
  --card-face: url("img/face-rose.webp?v=4c1bac4358");
  --card-emblem: url("img/emblem-rose.webp?v=d784bcb6d0");
  --card-paper: #e8d8d1;
  --card-ink: #9b1239;
}

.card:not(.revealed):not(.busy):hover { transform: translateY(-6px); }
.card:not(.revealed):active { transform: translateY(-2px) scale(.985); }

.card.busy { pointer-events: none; animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .55; } }

.card.revealed { transform: perspective(1200px) rotateY(180deg); cursor: default; }

/* Everything on the turned side, appended by app.js after Stripe confirms: the logo,
   the message, and the two controls. One wrapper rather than three siblings because
   translateZ is load-bearing and only needs saying once.
   Why translateZ at all: .card is preserve-3d, and the face (::after) sits at exactly
   the same depth as this layer, so the tie is broken by tree order and a pseudo-element
   after the children wins it. The paper paints OVER the message and a paid card turns up
   blank. Found 2026-09-28 while landing Thessa's art, and it predates the art. Mind the
   axis if you touch this: the translate happens in the already-flipped frame, so this
   sits at z = -1px in card space, and it reads as one pixel FORWARD only because the
   whole card is then rotated 180 and you are looking down that axis reversed. Do not
   fold it back into a flat rotateY(180deg).
   The padding is measured off the art, and mind the units: a percentage padding resolves
   against WIDTH on every side, so on a 7 : 12 card a vertical figure has to be multiplied
   by 12/7 to mean what it says. Her dashed border sits about 7% of the height down from
   the top and the bottom stars about 10% up, which is the 17% / 19% below. The side
   chains come further in than the border does, but only across the middle third, so the
   message gets its own inset rather than squeezing the logo and the buttons with it. */
.card .face {
  position: absolute;
  inset: 0;
  transform: rotateY(180deg) translateZ(1px);
  backface-visibility: hidden;
  display: grid;
  grid-template-rows: 1fr auto;
  align-items: center;
  justify-items: center;
  gap: 4%;
  /* Sides went 15% -> 19% on 2026-09-29: "Let op de achterkant van de kaartjes op de
     padding aan de zijkanten. Die vond ik bij je preview iets te krap." That is 4% of
     the card's width off each side of the message, which the longest card in the deck
     still fits at the same type size. test/browser.mjs re-measures it.
     19% -> 16% on 2026-09-29 (evening): "Tekst mag ieeeetsjes breder". Three points of
     the card's width back on each side, which is about two characters a line.
     drawCard() in app.js carries the same figure and they have to move together, or
     the picture someone shares wraps differently from the card they were holding. */
  padding: 17% 16% 13%;
  border-radius: var(--radius);
  overflow: hidden;
}

/* The message.
   Joost, 2026-09-29, three separate asks that land in this one rule:
     "fontje Bright or Cardo bold"          -> --font-serif, weight 700
     "genoeg spacing om niet te dicht op de randen te komen"  -> the 15% / 17% / 13%
        padding on .face above, plus the 4% gap over the emblem
     "de fontgrootte moet altijd hetzelfde zijn, dus de langste tekst moet passen"
        -> ONE size for every card. There is no .long step down any more and there is no
           clamp: the size is a fixed fraction of the card's own width, so a 95 character
           card and the 368 character one are set identically and the short ones simply
           carry more paper. .052 is measured, not guessed: at that fraction the longest
           card in the deck fills the box with a line to spare at every size the card is
           ever drawn. test/browser.mjs re-measures it.
   Sentence case, not caps. The backs are caps because that is how the deck is printed,
   but 368 characters of uppercase serif is a wall, and the deck is written in sentences
   with punctuation that uppercase throws away. */
/* The number on a number card, set as a title over the message. Joost, 2026-09-29:
   "Qua design zijn de number cards zelfde alleen het nummer staat groter als titel
   boven de tekst". The same serif as the message so the card reads as one object, at
   three times the size, and the gap under it is what separates them rather than a rule
   or a colour. Only the 37 cards in NUMBER-CARDS.md have this element at all. */
.card .text .num {
  display: block;
  margin: 0 0 .28em;
  font-size: calc(var(--card-w-row) * .155);
  font-weight: 700;
  line-height: 1;
  letter-spacing: .01em;
}

.card .text {
  align-self: stretch;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: safe center;
  color: var(--card-ink, #16203c);
  font-family: var(--font-serif);
  font-weight: 700;
  font-size: calc(var(--card-w-row) * .052);
  line-height: 1.34;
  letter-spacing: 0;
  text-align: center;
  text-wrap: pretty;
  overflow: hidden;
}

/* The sun and moon, small and centred along the bottom edge. Joost, 2026-09-29. It
   replaced the full lockup that used to sit at the TOP of this side: the wordmark is
   now on the page itself, above the spread, so repeating it on every card said the same
   thing twice and ate the height the message needed.
   An element rather than an <img> so the colourway comes off the same per-slot variable
   as the paper and the back. It is only given a size in the enlarged state: at 110px
   wide in the row it would be four pixels of mush, and with no width it is a zero-sized
   grid item so the row simply collapses and the message takes the whole card. */
.card .emblem {
  background: var(--card-emblem) center / contain no-repeat;
}
.card.open .emblem {
  width: 9.5%;
  aspect-ratio: 1;
}

/* ---- the tray ----
   Everything you can DO with a card you have paid for, sitting under it, OUTSIDE it.
   Joost, 2026-09-29: "De staat van het kaartje en tekst mag niet veranderen als de
   kaart gedeeld oid wordt". The buttons used to live inside .face, which meant the card
   changed as you used it (a button greying out to say "Link copied" is the card
   changing) and it meant the thing on screen was not the thing you would be sharing.
   Out here the card is only ever paper, ink and the emblem, so the picture the share
   button produces is exactly what is on screen and stays that way.
   Fixed to the WINDOW, same as the open card, so the two agree by construction rather
   than by arithmetic. That only became true on 2026-09-29 when the perspective moved off
   .spread and into each card's own transform (see .spread): until then .spread was the
   containing block and both of these were centred on it. */
#tray {
  position: fixed;
  z-index: 41;
  left: 50%;
  /* The card is lifted by half the tray so the pair of them is centred (see
     .card.open's margin-top), and this has to take the SAME lift or the tray lands half
     a tray below the card and runs off the bottom of a phone. Card half-height is
     width * 6/7 on a 7 : 12 card. */
  top: calc(var(--open-cy) + var(--card-w-open) * 6 / 7 + 13px);
  transform: translateX(-50%);
  width: min(var(--card-w-open) + 40px, 94vw);
  display: none;
  flex-direction: column;
  gap: 8px;
  text-align: center;
}
.spread:has(.card.open) #tray,
/* Same switch written twice on purpose. :has() is the nice version; .reading is set on
   <html> by openCard. If only the :has() rule existed, a browser without it would show
   the open card with NO share button and NO way to put it back down, which is a dead
   end on the one screen someone has just paid for. The class fails the safe way. */
.reading #tray { display: flex; }

.tray-row {
  display: flex;
  gap: 8px;
}

.act {
  flex: 1 1 0;
  min-width: 0;
  font: inherit;
  font-family: var(--font-caps);
  font-size: clamp(9px, 3vw, 12px);
  letter-spacing: .07em;
  text-transform: uppercase;
  padding: .85em .4em;
  border-radius: 999px;
  /* colour-mix off the label rather than a second hand-picked value, so the ring can
     never drift to a different hue from the word inside it. */
  border: 1px solid color-mix(in srgb, var(--on-bg) 45%, transparent);
  background: transparent;
  color: var(--on-bg);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: opacity .2s ease;
}
.act:active { transform: scale(.97); }
/* Mid-share, while the picture is being drawn. There is no "done" state any more: the
   button hands the picture to the phone and the phone's own sheet is the feedback. */
.act[data-state="busy"] { opacity: .55; cursor: progress; }

/* The prompt, and the way back to the other two. Joost's own words, 2026-09-29:
   "Go back to the other 2 cards". It was phrased as a question until he read it back
   and asked whether it was a bit pushy, which it was: the count is information, but
   asking someone whether they want to see the cards lying in front of them is a pitch.
   It is a BUTTON rather than a line of copy with a separate Continue underneath, because
   going back to the spread IS the thing the line describes, and two controls for one
   intention is one control too many on a phone. */
.tray-prompt {
  width: 100%;
  font: inherit;
  font-family: var(--font-caps);
  font-size: clamp(10px, 3.3vw, 13px);
  line-height: 1.35;
  letter-spacing: .04em;
  padding: .8em 1em;
  border-radius: 14px;
  border: 1px solid transparent;
  background: var(--on-bg);
  color: var(--bg);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.tray-prompt:active { transform: scale(.98); }
/* Nothing left to buy: it stops being an offer and becomes a way to put the card down. */
.tray-prompt[data-done="1"] {
  background: transparent;
  border-color: color-mix(in srgb, var(--on-bg) 45%, transparent);
  color: var(--on-bg);
}

/* ---- shuffle again ----
   Joost, 2026-09-29: "ik wil een shuffle again optie hebben na de 3 kaarten omgedraaid
   zijn". It appears only when all three are turned, and it is deliberately the same
   shape as a tray button rather than something louder: it costs a euro a card again,
   and the price line comes back above it to say so. */
.again {
  font: inherit;
  font-family: var(--font-caps);
  font-size: clamp(11px, 3.4vw, 14px);
  letter-spacing: .07em;
  text-transform: uppercase;
  padding: .85em 1.6em;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--on-bg) 45%, transparent);
  background: transparent;
  color: var(--on-bg);
  cursor: pointer;
  transition: opacity .3s ease;
}
.again:active { transform: scale(.98); }
.again[hidden] { display: none; }
/* Out of the way while a card is being read, same as the price and the fine print.
   Not the real guard: openCard sets [hidden] on the button, because this rule fails OPEN
   without :has() and a tap here wipes all three cards mid-flip. This is the fade for
   the browsers that can do it. */
main:has(.card.open) .again {
  opacity: 0;
  pointer-events: none;
}

/* The re-deal. The three cards turn back over and hop, staggered by app.js rather than
   by nth-child, because the deck element is removed after the opening and the slots
   change position in the tree. A keyframe rather than a transition so the lift and the
   landing are one gesture. */
@keyframes redeal {
  0%   { transform: translateY(0) rotate(0deg) scale(1); }
  38%  { transform: translateY(-20px) rotate(var(--redeal-r, 0deg)) scale(1.04); }
  100% { transform: translateY(0) rotate(0deg) scale(1); }
}
.card.redeal { animation: redeal .62s cubic-bezier(.3, .7, .3, 1) both; }

/* ---- the picture, when the phone will not share it ----
   Every browser that can put a file into the OS share sheet does (see shareArt), and
   this is what the rest get: the picture itself, full screen, to press and hold. An
   in-app webview is the common case, which is most of this site's traffic, and until
   2026-09-29 it got a toast reading "Could not make the picture" while the picture sat
   finished in memory. */
.shot {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 18px;
  background: color-mix(in srgb, var(--bg) 88%, #000);
}
.shot[hidden] { display: none; }
.shot img {
  max-width: 100%;
  max-height: 72vh;
  border-radius: 12px;
  box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
}
.shot p {
  margin: 0;
  font-family: var(--font-caps);
  font-size: clamp(10px, 3.2vw, 13px);
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--on-bg);
  text-align: center;
}
.shot-close {
  font: inherit;
  font-family: var(--font-caps);
  font-size: clamp(10px, 3.2vw, 13px);
  letter-spacing: .07em;
  text-transform: uppercase;
  padding: .8em 2em;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--on-bg) 45%, transparent);
  background: transparent;
  color: var(--on-bg);
  cursor: pointer;
}

/* While one card is being read, the other two step back. Without this the enlarged
   card sits ON TOP of the row and the third card pokes out past its right edge as a
   stray sliver, which looks like a rendering fault rather than a deck. :has is the
   whole mechanism, so a browser without it simply keeps the old look, nothing breaks. */
.spread:has(.card.open) .card:not(.open) {
  opacity: .18;
  transition: opacity .45s ease;
}

/* The price and the fine print step back with them. They live OUTSIDE .spread, below
   it, and the tray is fixed and rides up as the card shrinks, so at 68vw the two
   collided: "EUR 1 to cover costs" was painting straight through the Share this card
   row. Fading rather than hiding, so nothing reflows underneath while the card is up
   and the line is back the instant it goes down, which is when it means something
   again. The selector is main:has rather than .spread:has because they are the open
   card's uncles, not its cousins. */
main:has(.card.open) .price,
main:has(.card.open) .fineprint {
  opacity: 0;
  pointer-events: none;
  transition: opacity .35s ease;
}

/* A revealed card grows so the message is actually readable on a phone. */
.card.open {
  position: fixed;
  z-index: 40;
  inset: auto;
  top: var(--open-cy);
  left: 50%;
  /* Height follows from the width now that the card is 70 x 120 rather than 2 : 3,
     and 400px wide is 686px tall. On an SE with Safari's chrome on screen that is
     taller than the window, so the third term caps the width by what the viewport
     can actually give back vertically. dvh rather than vh because the URL bar
     counts: vh is the tall-bar figure and would let it overflow again the moment
     the bar is showing. The vh line stays as the fallback for anything without dvh.
     The card is centred on the window. */
  width: var(--card-w-open);
  margin-top: 0;
  transform: translate(-50%, -50%) perspective(1200px) rotateY(180deg);
  /* Joost, 2026-09-29: "laat hem ietsjes zwevend pulseren". Five pixels each way over
     five seconds, with the shadow breathing underneath so it reads as the card hanging
     in the air rather than the card sliding. The delay is the flip's own .6s plus a
     beat: start it any sooner and the float fights the travel out of the row and the
     turn looks like a wobble. The keyframes repeat the whole transform because an
     animation REPLACES the property, and dropping the perspective/rotateY here would
     flip the card back to its blank side mid-hover. */
  animation: card-float 5s ease-in-out .95s infinite;
}

/* While the card is travelling out of the row, app.js drives the transform itself
   (flipOpen) and the float has to stay out of the way: an animation REPLACES the
   property, so without this the float snaps the card to the centre halfway through
   the journey. It is removed when the travel ends and the float takes over from
   there. See flipOpen() in app.js, which owns the timing. */
.card.flipping {
  animation: none;
  will-change: transform;
}

@keyframes card-float {
  0%, 100% {
    transform: translate(-50%, calc(-50% - 5px)) perspective(1200px) rotateY(180deg);
    box-shadow: 0 26px 46px rgba(0, 0, 0, .34);
  }
  50% {
    transform: translate(-50%, calc(-50% + 5px)) perspective(1200px) rotateY(180deg);
    box-shadow: 0 14px 26px rgba(0, 0, 0, .26);
  }
}
/* The enlarged card is capped by the VIEWPORT HEIGHT as well as by 400px (see
   .card.open), so in landscape it is about 195px wide while 3.3vw would be 28px. Type
   that scales with the viewport width alone GROWS as the card shrinks, which is
   backwards, so the size is a fraction of the card's own width instead. Same .052 as
   the row card, which is the whole point: one size, everywhere, for every card.
   overflow-y: the longest card in the deck is 368 characters and fits by construction,
   but a browser that fails to load the serif falls back to Georgia at a different
   width. Somebody paid a euro for those words, so the block scrolls rather than
   cutting them off if that ever happens. */
.card.open .text {
  font-size: calc(var(--card-w-open) * .052);
  overflow-y: auto;
  overscroll-behavior: contain;
}

/* The number has to be re-stated off --card-w-open for the same reason the message is,
   otherwise it keeps the row card's width and the title comes out barely larger than
   the sentence under it. */
.card.open .text .num {
  font-size: calc(var(--card-w-open) * .155);
}

/* ---- a phone on its side ----
   The stacked layout above spends 178px of HEIGHT on the tray, and a phone in landscape
   has about 390px of it in total. Subtracting the tray there left a 100px card, which is
   SMALLER than the same card lying in the row, and a tray so narrow the four labels wrapped
   to 244px tall and ran off the bottom of the screen. Turning a card has to make it bigger,
   always.
   So on a short, wide screen the tray moves to the RIGHT of the card and spends width, of
   which there is plenty, instead of height, of which there is none. The card is then sized
   by height alone, and the pair is shifted left by half the tray so the two of them together
   still sit in the middle. Found 2026-09-29 by the landscape leg of the browser suite. */
@media (orientation: landscape) and (max-height: 560px) {
  :root {
    /* No tray underneath any more, so the card gets the whole height back, and no
       wordmark above it either: on a short screen the card is centred on the window
       and the header scrolls out of its way. */
    --card-w-open: min(46vw, 400px, calc((100vh - 32px) * 7 / 12));
    --card-w-open: min(46vw, 400px, calc((100dvh - 32px) * 7 / 12));
    --open-cy: 50%;
  }
  .card.open {
    margin-top: 0;
    /* Half the tray plus the gap, so card + tray is centred rather than the card alone. */
    margin-left: calc((var(--tray-w) + 16px) / -2);
  }
  #tray {
    /* Left edge of the tray = right edge of the card + the gap. Both derive from the same
       two lengths, so they cannot drift apart. */
    left: calc(50% - (var(--tray-w) + 16px) / 2 + var(--card-w-open) / 2 + 16px);
    top: 50%;
    transform: translateY(-50%);
    width: var(--tray-w);
    max-height: 100dvh;
  }
  /* Four buttons in one column: at 236px the two-up row wrapped its labels. */
  .tray-row { flex-direction: column; }
}

/* ---- copy ---- */

/* The only resting-state text under the cards, and there is one of it. Joost changed
   this on 2026-09-28 from a EUR 1 tag under every card plus a separate line reading
   "donation": one price, once, and what it is for. */
.price {
  margin: clamp(12px, 2.8vw, 18px) 0 0;
  display: flex;
  align-items: baseline;
  justify-content: center;
  /* Split since 2026-10-03, when .why-sub was added on its own wrapped line. A single
     `gap` applied .55em VERTICALLY too, which hung the small print well below the price
     and read as an unrelated sentence rather than a footnote to it. */
  column-gap: .55em;
  row-gap: 4px;
  flex-wrap: wrap;
  line-height: 1;
  /* Inherits --on-bg like everything else. It used to carry its own blue-grey variable,
     which survived the 2026-09-29 recolour and left "EUR 1 to cover costs" sitting in
     the old ink on the beige. Joost spotted it: "Waarom is er nog blauwe tekst op de
     beige bg?" There is one page ink and this is not an exception to it. */
  color: inherit;
}

.price .amount {
  font-family: var(--font-caps);
  font-size: clamp(16px, 4.4vw, 22px);
  font-variant-numeric: tabular-nums;
}

.price .why {
  font-family: var(--font-caps);
  font-size: 12px;
  letter-spacing: .14em;
  /* Caps, not lowercase, since 2026-09-29: the deck's face is a caps design and its
     lowercase is a synthesised afterthought. */
  text-transform: uppercase;
  /* Dimmed on the child, not the whole line, so the amount stays at full strength.
     The predecessor of this line sat at .45, measured about 3.2:1 against the
     background and failed AA; it was raised to .68 and this keeps that floor. It is
     the only place the visitor is told what the euro is for, so it has to be legible
     rather than merely tasteful. */
  opacity: .72;
}

/* The quiet half of the price line, added 2026-10-03. flex-basis: 100% is what puts it on
   its own line inside the same flex row, so it stays one paragraph to a screen reader and
   one element to the intro animation. */
.price .why-sub {
  flex-basis: 100%;
  font-family: var(--font-caps);
  font-size: 10px;
  letter-spacing: .12em;
  text-transform: uppercase;
  /* Small, NOT faint. The obvious way to make a line recede is to drop the opacity, and
     the predecessor of .why did exactly that at .45, measured about 3.2:1 and failed AA.
     This line is the one that says where the money goes, so it holds the same .72 floor as
     .why and gets its quietness from size alone. */
  opacity: .72;
}

.price[hidden] { display: none; }

.fineprint {
  margin: -6px 0 0;
  font-family: var(--font-caps);
  font-size: 13px;
  letter-spacing: .06em;
  text-transform: uppercase;
  opacity: .55;
  max-width: 30ch;
  min-height: 0;
}

.fineprint:empty { display: none; }

/* ---- the opening: shuffle, deal, then the price ---- */

/* Three beats: the deck shuffles while /api/config loads, the three cards fly out of it,
   then the headline and the one price line come up. 2.2s and 0.8s since 2026-10-03, down
   from 5s and 1.85s, because Joost capped the whole thing at three seconds: "het schudden
   moet sneller trouwens, graag max 3 sec wachttijd". It was deliberately slow before that
   and it is deliberately short now, and it has been rewritten twice: once because the first
   pass read as a loading spinner, once to fit the cap without going back to one.
   Every duration here has a twin in app.js (INTRO and BEAT): change one, change both.
   test/api.test.mjs reads both files and fails if they disagree.
   Two rules keep this from ever costing a sale:
     - nothing is hidden by default. The html.intro class does the hiding and both
       app.js and the inline failsafe in index.html remove it, so a broken script
       leaves a plain working page rather than an empty one.
     - the cards are visibility:hidden, not display:none, so the row still has its
       real size during the shuffle and the deal lands where the cards actually are. */

.deck {
  display: none;
  position: absolute;
  top: 0;
  left: 50%;
  width: calc((100% - 2 * var(--spread-gap)) / 3);
  max-width: var(--card-max);
  aspect-ratio: 70 / 120;
  transform: translateX(-50%);
  pointer-events: none;
}

.deck i {
  position: absolute;
  inset: 0;
  border-radius: var(--radius);
  background-image: var(--card-back, url("img/back-blue.webp?v=9ef95612ab"));
  background-size: cover;
  background-position: center;
  background-color: #1b2440;
  box-shadow: 0 8px 22px rgba(0, 0, 0, .45);
  /* Resting offsets (--ox/--oy/--or) come from settle() in app.js, which steps them by
     depth so the stack shows seven layers. That fan is load-bearing, not decoration: a
     card whose home is at the BACK of a flat stack is invisible when it gets there, and
     the shuffle reads as cards vanishing. Flatten this and that bug comes back. */
  transform: translate(var(--ox, 0px), var(--oy, 0px)) rotate(var(--or, 0deg));
  /* Used by the six cards that are NOT in the air: when the stack reorders, they each
     shift up one step to make room. The returning card is on the .home keyframe below.
     Kept at or under BEAT.home (480) on purpose: the stack has to have finished reorganising
     by the time the card it made room for arrives, or the landing is into a pile still
     moving. test/api.test.mjs holds the relationship. */
  transition: transform .45s cubic-bezier(.36, .05, .16, 1), box-shadow .45s ease;
}

/* Seven layers, three designs, cycled so the stack does not look like one card
   photocopied. Only the top two or three are ever visible in the fan, the rest
   are there for depth.
   Keyed on data-n, which is the CARD, and NOT on nth-child, which is its position in
   the stack. Joost, 2026-09-29: "shuffle toont geen oranje cards". The reason was
   nasty: settle() reorders the DOM on every pull, so with nth-child the colourway
   stayed nailed to the SLOT while the cards travelled through it. The last child is
   the top of the stack, 7 = 3n+1, so the card you could actually see was blue every
   single time, for the whole shuffle, forever. Now the design travels with the card,
   and the amber one is the card that starts on top. */
.deck i[data-n="1"],
.deck i[data-n="4"],
.deck i[data-n="7"] { --card-back: url("img/back-amber.webp?v=1cfb426811"); }
.deck i[data-n="2"],
.deck i[data-n="5"] { --card-back: url("img/back-rose.webp?v=0af2db84d3"); }

/* 1. the card rises clear of the stack and hangs there. Without this the shuffle looks
      like cards sliding across a table rather than a hand picking one up. */
.deck i.lift {
  transform: translate(var(--ox, 0px), calc(var(--oy, 0px) - 16px)) rotate(var(--or, 0deg)) scale(1.06);
  box-shadow: 0 16px 30px rgba(0, 0, 0, .5);
  transition-duration: .22s;
  transition-timing-function: cubic-bezier(.25, .7, .3, 1);
}

/* The card currently being pulled off the top. It keeps its paint order until it is
   out of the way, then app.js moves it to the front of the container, which puts it
   at the BACK of the stack. That reordering is the shuffle. The first version only
   fanned the cards sideways: everything moved and nothing changed places, so the same
   card sat on top for the whole shuffle. Joost called it, 2026-09-28. */
/* 2. it travels out, and STAYS out. app.js holds it here (BEAT.hold) before moving it,
      and that pause is most of what makes the shuffle feel deliberate rather than fast.
      It survived the 2026-10-03 speed-up for exactly that reason. The easing is slow at
      both ends, so nothing ever snaps. */
.deck i.fly {
  transform: translate(var(--fx), var(--fy)) rotate(var(--fr)) scale(1.05);
  box-shadow: 0 22px 40px rgba(0, 0, 0, .55);
  transition-duration: .38s;
  transition-timing-function: cubic-bezier(.36, .05, .25, 1);
}

/* The return trip, and the whole reason this is a keyframe and not a transition: it has
   to be an ARC. A transition interpolates in a straight line, so the card came back at
   deck height, passed BEHIND the stack a third of the way in, and spent the rest of the
   journey completely hidden. Joost, twice: "they go left and right and then vanish
   instead of going down and back into the deck." The motion was right and occlusion was
   eating it.

   So the card dips below the pile first, where there is empty space and nothing to hide
   behind, and rises into the bottom of the fan at the end. It stays readable the whole
   way and the last thing you see is it tucking under. The midpoint also scales to .975,
   which is the cheap trick that says "passing underneath" rather than "passing in front".

   --fx/--fy/--fr are where it is now, --ox/--oy/--or are where it belongs in the stack,
   and app.js has already re-settled the other six by the time this starts. */
.deck i.home {
  animation: card-home .48s cubic-bezier(.38, .04, .2, 1) forwards;
}

@keyframes card-home {
  0% {
    transform: translate(var(--fx), var(--fy)) rotate(var(--fr)) scale(1.05);
    box-shadow: 0 22px 40px rgba(0, 0, 0, .55);
  }
  52% {
    transform: translate(calc(var(--fx) * .24), 40%) rotate(calc(var(--fr) * .22)) scale(.97);
    box-shadow: 0 14px 30px rgba(0, 0, 0, .5);
  }
  /* --hx/--hy/--hr and NOT --ox/--oy/--or: a custom property read by a running animation is
     live, so when the next pull lands and re-settles the stack this card's own destination
     would move under it, four pixels, in the last frames of the arc. app.js freezes the
     target into these three at the moment the card is prepended. */
  100% {
    transform: translate(var(--hx, 0px), var(--hy, 0px)) rotate(var(--hr, 0deg)) scale(1);
    box-shadow: 0 8px 22px rgba(0, 0, 0, .45);
  }
}

html.intro .deck { display: block; animation: breathe 5.4s ease-in-out infinite; }

/* The deck is never completely still, even between pulls. A 1.8% swell over five
   seconds is under the threshold you would call movement, and it is the difference
   between a held object and a picture of one. Deliberately NOT scaled down with the
   rest of the opening on 2026-10-03: the whole point is that it is too slow to read as
   motion, so only the first 40% of the cycle is ever seen now, and that is fine. */
@keyframes breathe {
  0%, 100% { transform: translateX(-50%) scale(1); }
  50%      { transform: translateX(-50%) scale(1.018); }
}
html.intro:not(.deal) .card { visibility: hidden; }
html.intro .card { pointer-events: none; }
html.intro h1,
html.intro .price { opacity: 0; }

/* the deal */
html.intro.deal .deck { animation: deck-out .46s ease .10s forwards; }

@keyframes deck-out {
  to { opacity: 0; transform: translateX(-50%) scale(.9); }
}

html.intro.deal .card {
  animation: deal .38s cubic-bezier(.2, .7, .18, 1) backwards;
}
html.intro.deal .slot:nth-child(2) .card { animation-delay: .10s; }
html.intro.deal .slot:nth-child(3) .card { animation-delay: .20s; }

/* --dx / --dy are measured in app.js: how far this card sits from the deck. */
@keyframes deal {
  from {
    opacity: .6;
    transform: translate(var(--dx, 0px), var(--dy, 0px)) rotate(var(--dr, 0deg)) scale(.98);
  }
  to { opacity: 1; transform: none; }
}

/* the price, last */
html.intro.deal h1 { animation: settle .28s ease .16s backwards; opacity: 1; }
html.intro.deal .price { animation: settle .28s ease .40s backwards; opacity: 1; }

@keyframes settle {
  from { opacity: 0; transform: translateY(7px); }
  to   { opacity: 1; transform: none; }
}

/* ---- payment sheet ---- */

/* ---- the offer for the other two, after the first card is put down ---- */
/* Joost, 2026-10-06: a pop-up instead of a second option on the first payment sheet.
   It borrows the sheet's own language on purpose, same panel, same corner radius, same two
   animations, because it is the screen immediately before the sheet and a different shape
   here would read as a different site.

   z-index 55 sits ABOVE the open card (40) and the tray (41) and BELOW the sheet (60). The
   two are never up at once (onTap and takeTheRest both close this first), and if that ever
   breaks, the screen that takes the money has to be the one on top. */
.upsell { position: fixed; inset: 0; z-index: 55; }
.upsell[hidden] { display: none; }

.upsell-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(4, 7, 18, .62);
  animation: fade .2s ease;
}

.upsell-panel {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: #fbfcff;
  color: #16203c;
  border-radius: 18px 18px 0 0;
  padding: 22px 20px max(20px, env(safe-area-inset-bottom));
  animation: up .28s cubic-bezier(.2, .8, .2, 1);
}

@media (min-width: 620px) {
  .upsell-panel {
    left: 50%;
    bottom: auto;
    top: 50%;
    right: auto;
    width: 420px;
    transform: translate(-50%, -50%);
    border-radius: 18px;
    animation: none;
  }
}

.upsell-x {
  position: absolute;
  top: 10px;
  right: 12px;
  border: 0;
  background: none;
  font-size: 26px;
  line-height: 1;
  color: #8b93ab;
  cursor: pointer;
}

/* The price sits at the right-hand end of the button rather than inside the sentence, so the
   number is in the same place it is on the options in the sheet below.

   The heading and the prompt line above this button were removed 2026-10-06 (Joost: the button
   is the only text the pop-up needs), so the top margin here is what keeps the button clear of
   the close cross, which is absolutely positioned at top 10px and is about 26px tall. */
.upsell-go {
  margin: 20px 0 0;
  display: flex;
  align-items: baseline;
  gap: 10px;
  width: 100%;
  padding: 15px;
  border: 0;
  border-radius: 11px;
  background: var(--ink);
  color: #fff;
  font: inherit;
  font-size: 16px;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
}
.upsell-go span:first-child { flex: 1; }
.upsell-price { font-weight: 600; white-space: nowrap; }

/* Quiet, but a real button with a real hit area. "Not now" has to be as easy to press as the
   offer: a dismissal that is harder to hit than the purchase is a dark pattern. */
.upsell-no {
  display: block;
  width: 100%;
  margin: 6px 0 0;
  padding: 13px;
  border: 0;
  border-radius: 11px;
  background: none;
  color: #6b7391;
  font: inherit;
  font-size: 15px;
  cursor: pointer;
}

.sheet { position: fixed; inset: 0; z-index: 60; }
.sheet[hidden] { display: none; }

.sheet-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(4, 7, 18, .72);
  backdrop-filter: blur(3px);
  animation: fade .2s ease;
}
@keyframes fade { from { opacity: 0; } }

.sheet-panel {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: #fbfcff;
  color: #16203c;
  border-radius: 18px 18px 0 0;
  padding: 20px 20px max(20px, env(safe-area-inset-bottom));
  max-height: 88dvh;
  overflow-y: auto;
  animation: up .28s cubic-bezier(.2, .8, .2, 1);
}
@keyframes up { from { transform: translateY(100%); } }

@media (min-width: 620px) {
  .sheet-panel {
    left: 50%;
    bottom: auto;
    top: 50%;
    right: auto;
    width: 420px;
    transform: translate(-50%, -50%);
    border-radius: 18px;
    animation: none;
  }
}

.sheet-close {
  position: absolute;
  top: 10px;
  right: 12px;
  border: 0;
  background: none;
  font-size: 26px;
  line-height: 1;
  color: #8b93ab;
  cursor: pointer;
}

.sheet-title { margin: 2px 0 0; font-size: 15px; color: #6b7391; }
.sheet-amount { margin: 2px 0 16px; font-size: 30px; font-weight: 600; }

/* ---- the two options: one card, or the whole spread ---- */
/* Two rows rather than two columns. Stacked, each option is a full-width target with room
   for a label and a price on the same line, which is what a thumb wants; side by side on a
   narrow phone the longer label wraps to two lines and the two options stop looking like a
   pair. */
.choice {
  display: grid;
  gap: 8px;
  margin: 0 0 16px;
}
.choice[hidden] { display: none; }

.opt {
  display: flex;
  /* Kept after the saving line was removed on 2026-10-03. It is what stops a long label
     being squeezed into a three-line column next to the price ("All / three / cards" in a
     screenshot that day) if anything is ever added back into this row. The browser test
     under this measures the label's own height for exactly that. */
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  width: 100%;
  padding: 13px 14px;
  border: 1.5px solid #e2e6f0;
  border-radius: 11px;
  background: #fff;
  color: var(--ink);
  font: inherit;
  font-size: 15px;
  text-align: left;
  cursor: pointer;
}
/* The selected one carries the ink border AND a tinted fill. Border alone is too quiet a
   signal for which of two prices is about to be charged, and colour alone fails for anyone
   who cannot see it: aria-checked carries the same fact to a screen reader. */
.opt[aria-checked="true"] {
  border-color: var(--ink);
  background: #f4f6fc;
}
.opt:disabled { opacity: .55; cursor: default; }
.opt-what { flex: 1; }
/* Never the thing that gives way when the label is long: a wrapped price reads as two
   prices. */
.opt-price { font-weight: 600; white-space: nowrap; }

#express:empty { display: none; }

.or {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 16px 0;
  color: #98a0b8;
  font-size: 12px;
}
.or::before, .or::after { content: ""; flex: 1; height: 1px; background: #e2e6f0; }
.or[hidden] { display: none; }

.pay {
  width: 100%;
  margin-top: 16px;
  padding: 15px;
  border: 0;
  border-radius: 11px;
  background: var(--ink);
  color: #fff;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
}
.pay:disabled { opacity: .5; cursor: default; }

.sheet-error {
  margin: 12px 0 0;
  color: #b4304a;
  font-size: 14px;
}
.sheet-error[hidden] { display: none; }

.sheet-legal { margin: 14px 0 0; font-size: 11px; color: #98a0b8; text-align: center; }

/* ---- toast ---- */

.toast {
  position: fixed;
  left: 50%;
  bottom: max(22px, env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 70;
  /* Nothing in here is ever clickable, and it sits above everything including the pop-up and
     the payment sheet. Without this it swallowed taps on whatever it covered: measured
     2026-10-06 on a 390px screen, "Paid. Turning your card..." sat exactly over the pop-up's
     "Not now" while leaving the EUR 1 button clear, which is a dark pattern by accident. */
  pointer-events: none;
  padding: 11px 16px;
  border-radius: 999px;
  background: rgba(10, 14, 30, .94);
  border: 1px solid rgba(203, 212, 238, .25);
  font-size: 14px;
}
.toast[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
  /* belt and braces: the inline switch already skips the intro here, but if the class
     ever lands anyway, nothing must stay hidden. */
  .deck { display: none !important; }
  html.intro .card { visibility: visible !important; pointer-events: auto !important; }
  html.intro h1, html.intro .price { opacity: 1 !important; }
}

/* ---- the bottom line (.bottom-link) ---- LIVE again since 2026-10-08 ----
   The word COLOFON, fixed to the bottom edge of the card page, linking /colofon/. Here from
   2026-10-02 (the day iDEAL went live), off the page from 2026-10-04 on Joost's ask, back on
   2026-10-08 on his go: "Go ahead and reinstate the colofon". The block was kept matching
   nothing for those four days rather than deleted, which is why today's restore was an
   anchor and two numbers instead of an afternoon of measuring.

   Why the page has it at all: Stripe's iDEAL rules require a business established in the
   Netherlands to display its KVK number on the site, and until 2026-10-02 universemessage.com
   showed nothing about who was being paid. It doubles as the place an unhappy buyer can go
   that is not their bank. Under a euro is small enough that "who even was that" is a likelier
   reaction than a refund request, and an unexplained line on a statement is how a chargeback
   starts.

   It is one word, deliberately the quietest thing on the page. It is NOT inside #stage: that
   flex column centres the spread and the price in the height left under the wordmark, and a
   seventh child would be handed a share of that centring and pull the cards up off the middle.
   Fixed costs the layout nothing. */
.bottom-link {
  position: fixed;
  left: 50%;
  bottom: max(4px, env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 1;
  font-family: var(--font-caps);
  /* Flat 11px, which is the size the 44px tap target below was built around. One word of
     letterspaced caps measures about 96px, so it fits a 320px phone with room on both sides
     and has no reason to shrink. From 2026-10-04 this read clamp(9px, 2.6vw, 11px) with
     white-space: nowrap, added for the sentence "Help spread messages from the universe"
     that was going to replace the colofon and never shipped. That sentence is about 300px
     wide at 11px, which does not fit a 320px phone, and wrapping is not available at this
     edge because a second line walks up into an open card's tray. If a sentence ever takes
     this slot again, that pair comes back with it: shrinking type is loud, a silent reflow
     under the cards is not. */
  font-size: 11px;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: inherit;
  /* Quiet, but not dim enough to be deniable. This is the only route a buyer has to who
     took their euro, so "easily and directly accessible" has to survive measurement rather
     than just look tasteful: a line nobody can read is not a disclosure.
     Composited and measured against each sky:
     .72 on the navy is 7.2 : 1, which passes AA for text this size with room to spare.
     The beige is the hard one. Its ink is #96540b, which is 4.8 : 1 at FULL strength,
     so every dimming step on that theme spends contrast the page does not have:
     .72 there would be 2.8 : 1 and .38, where this started, 1.7 : 1. Hence the override
     below rather than one number for both. (The page's own .fineprint sits at .55 and
     therefore measures 2.2 : 1 on the beige. That is a real problem and it is not this
     one; it is noted in NOTES.md rather than quietly fixed from here.) */
  opacity: .72;
  text-decoration: none;
  /* The tap target, not the text. 11px of letterspaced caps is about 7px tall and a
     thumb does not aim that well, so the padding brings the hit area up to 44px. The
     element is fixed, so the padding pushes nothing around and needs no negative margin
     to pull back: an early draft had margin: -14px -18px and the -18px landed the word
     18px left of centre, because translateX(-50%) centres the MARGIN box. */
  padding: 14px 18px;
  transition: opacity .3s ease;
}

/* Full strength on the beige, where the ink has no contrast to give away. */
html[data-theme="day"] .bottom-link { opacity: 1; }

.bottom-link:hover,
.bottom-link:focus-visible { opacity: 1; }

/* A turned card is fixed to the middle of the window with its tray under it, and at the
   smallest card width the tray reaches down into this corner. Same treatment the price
   line gets: fade rather than hide, so nothing reflows while the card is up and the word
   is back the instant it goes down.

   Measured at 390x844, one word centred is about 96px wide and clears the share and
   continue buttons under the card, so this is margin rather than a rescue, which is also
   why the colofon survived four days in 2026-10-02's version without it. It stops being
   margin the moment the line grows: the sentence that nearly replaced it spanned 245px and
   overlapped the tray by about 11px, directly across the two buttons on a card somebody had
   just paid for.

   Written twice on purpose, exactly as #tray does it 770 lines up, and the duplication is
   sharper here. The tray fails closed without :has(); this one fails OPEN, leaving the word
   painted and clickable over the tray on a browser that does not support it. .reading is set
   on <html> by openCard() in app.js, so the second rule is the same state expressed in JS. */
html.reading .bottom-link {
  opacity: 0;
  pointer-events: none;
}

/* ---- the colofon page itself ----
   /colofon/, a plain static page. It shares this stylesheet for the two skies, the font
   and the wordmark, and adds nothing of its own that the card page can see. */
.page {
  max-width: 34rem;
  min-height: 100dvh;
  margin: 0 auto;
  padding: max(28px, env(safe-area-inset-top)) 22px max(28px, env(safe-area-inset-bottom));
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 18px;
  text-align: left;
}

.page-brand {
  display: block;
  align-self: center;
  margin-bottom: 6px;
}

/* #brand is a div on the card page and is sized by width plus aspect-ratio, neither of
   which an inline box obeys. */
.page #brand { display: block; }

.page-title {
  font-family: var(--font-caps);
  font-size: clamp(20px, 6vw, 26px);
  font-weight: 400;
  letter-spacing: .12em;
  text-transform: uppercase;
  margin: 0;
}

.page-facts {
  margin: 0;
  display: grid;
  /* Label column sized to the longest of the three rather than to a guess, so the
     values line up and nothing wraps at 320px. */
  grid-template-columns: auto 1fr;
  gap: 8px 18px;
  font-size: 15px;
}

.page-facts dt {
  font-family: var(--font-caps);
  font-size: 11px;
  letter-spacing: .14em;
  text-transform: uppercase;
  opacity: .6;
  /* The label is smaller than its value, so align the two on their first baseline
     instead of their boxes. */
  align-self: baseline;
}

.page-facts dd { margin: 0; }

/* The email is the one line here anybody is meant to act on, so it keeps the ink at
   full strength and carries an underline rather than a colour. There is no link colour
   in this stylesheet, and inventing one would have to clear 4.5 : 1 against both skies
   at once, which neither sky has room for. */
.page-facts a {
  color: inherit;
  text-underline-offset: 3px;
}

/* An email address is one unbreakable word and the grid's 1fr column sizes to it, so at
   320px (iPhone SE, still the narrowest phone that matters) the page picked up a
   horizontal scrollbar: measured scrollWidth 324 against clientWidth 320. Two changes,
   both measured rather than reasoned about. overflow-wrap lets the word break at all,
   and the <wbr> after the @ in the markup decides where, so it lands at the @ instead
   of mid-domain. `anywhere` was the first try and was wrong: it drops the dd's
   min-content contribution to nothing, the auto label column then takes its full
   max-content, and the address came out over three lines at 390px where it had fitted
   on one. `break-word` breaks the same way at paint time without touching the track
   sizing. */
.page-facts dd { overflow-wrap: break-word; }

/* Below this the two columns genuinely do not fit: the label column alone measures
   153px and the address wants about 195px inside a 276px content box. Stacking is the
   honest answer, rather than shrinking type nobody can read on the one page that has to
   be legible. */
@media (max-width: 430px) {
  .page-facts { grid-template-columns: 1fr; gap: 0; }
  .page-facts dt { margin-top: 14px; }
  .page-facts dt:first-of-type { margin-top: 0; }
  .page-facts dd { margin-top: 2px; }
}

.page-note {
  margin: 0;
  font-size: 14px;
  line-height: 1.55;
  opacity: .78;
}

.page-back {
  margin: auto 0 0;
  font-family: var(--font-caps);
  font-size: 12px;
  letter-spacing: .14em;
  text-transform: uppercase;
}

.page-back a { color: inherit; opacity: .7; }
.page-back a:hover { opacity: 1; }

/* The beige theme's ink (#96540b) measures 4.8 : 1 at full strength, so every opacity
   step below 1 on that sky is contrast spent from a budget that has none. On the card
   page that is a trade worth making, the dimmed lines there are decoration around a
   picture. On this page every line is the thing itself: who took the euro, for what,
   and where to look them up. So on the beige the page is simply not dimmed, and the
   hierarchy comes from the caps, the size and the letterspacing, which it already did.
   Measured: .78 on the beige is 3.4 : 1 and .6 is 2.5 : 1, both under AA for text this
   size. At 1 they are 4.8 : 1. The navy is untouched, it reads 9.9 : 1 and 7.9 : 1. */
html[data-theme="day"] .page-note,
html[data-theme="day"] .page-facts dt,
html[data-theme="day"] .page-back a { opacity: 1; }
