/* The Proposal Deck — a reveal sequence.
   Dark table, lit pool, cards dealt into it.
   Palette derives from the deck (WORKFLOW.md). This deck contains no pure black: the darkest
   value here is the sepia contour ink taken down, never #000. */

:root {
  /* the table */
  --table:     #1A1208;  /* contour ink taken down — warm, not neutral */
  --table-lit: #3A2810;  /* the deck's contour ink = the pool of light */
  --table-rim: #0E0904;

  /* the deck's own light values, now doing the work of text */
  --card:       #FCFBF8;
  --parchment:  #E3D7B2;
  --parch-soft: #A8946B;
  /* The sub line's own ink. --parch-soft measures 6.3:1 on the dark table but only 4.8:1 in the
     LIT POOL, which is where the copy actually sits — barely over WCAG AA, and AA is a floor set
     for young eyes. This is 6.9:1 in the pool, 9.0:1 outside it. The small caps labels keep
     --parch-soft; they are short, and this line carries the content. (PM 2026-08-01, on feedback
     that the subs read small for older viewers.) */
  --sub-ink:    #C4B48C;

  /* suit accents, lifted for a dark ground (the canonical darks disappear on it).
     --rings is the deck's own leaf-gold #D9B24C, not a new color. */
  --shields: #C4452C;
  --rings:   #D9B24C;
  --cups:    #5793AC;
  --flowers: #5A9A6E;

  /* the CANONICAL suit colors (WORKFLOW.md). These are what the cards actually carry, and
     what the color chips must show — the lifted values above exist only for dark-ground type. */
  --shields-true: #8E2F1F;
  --rings-true:   #C0963C;
  --cups-true:    #2E5E72;
  --flowers-true: #28503A;

  --accent: var(--parch-soft);

  --serif: "Hoefler Text", "Iowan Old Style", Palatino, "Palatino Linotype", Georgia, serif;
  --mono: "SF Mono", "IBM Plex Mono", Menlo, monospace;

  --deal: 760ms cubic-bezier(.16, .84, .32, 1);
  /* how long the incoming beat waits for the outgoing one to leave — see .beat.is-active */
  --enter: 200ms;
}

[data-act="shields"] { --accent: var(--shields); }
[data-act="rings"]   { --accent: var(--rings);   }
[data-act="cups"]    { --accent: var(--cups);    }
[data-act="flowers"] { --accent: var(--flowers); }

* { box-sizing: border-box; }

/* text-size-adjust: iOS Safari inflates type on a rotated page unless told not to, which would
   undo every size below at exactly the moment the layout is tightest.

   height: 100svh — THE fix for "it fits in Chrome but not in Edge on the same phone". Every iOS
   browser is WebKit, so it is not a rendering difference: it is that `height: 100%` and `vh` both
   resolve against the LARGE viewport (toolbars hidden), while the visible area is whatever the
   browser's own chrome leaves. Edge's is taller than Chrome's, so the same page overflowed there
   and fitted here. `svh` is the SMALL viewport — the area guaranteed visible with toolbars shown —
   so the beat box matches what she can actually see in any of them. Deliberately not `dvh`: that
   tracks the toolbars live, and since this page never scrolls, the art would resize under her as
   the chrome auto-hid. The `100%` line stays as the fallback for anything without svh. */
html, body {
  height: 100%;
  height: 100svh;
  margin: 0;
  overflow: hidden;
  -webkit-text-size-adjust: 100%;
}

body {
  background: var(--table);
  color: var(--parchment);
  font-family: var(--serif);
  -webkit-font-smoothing: antialiased;
  cursor: pointer;
}

/* The lit table: a warm pool where the cards land, falling off to a dark rim.
   Tinted by the act's accent, so each suit changes the temperature of the room. */
.table-light {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(ellipse 62% 58% at 50% 44%,
      color-mix(in srgb, var(--accent) 16%, var(--table-lit)) 0%,
      var(--table) 58%,
      var(--table-rim) 100%);
  transition: background 900ms ease;
}

.grain {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 3;
  opacity: .22;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml;utf8,\
<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'>\
<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4'/>\
<feColorMatrix type='saturate' values='0'/></filter>\
<rect width='180' height='180' filter='url(%23n)' opacity='.5'/></svg>");
}

/* ---- beats ---------------------------------------------------------------------------- */

.beat {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(1.2rem, 3vh, 2.6rem);
  padding: 3vmin 5vmin 6vmin;
  text-align: center;
  opacity: 0;
  visibility: hidden;
  /* THE BLINK FIX. `visibility` is discrete — without a delay it flips to hidden the instant
     .is-active comes off, so the outgoing beat stops being PAINTED while its opacity is still
     ~1. The fade then plays on an invisible element and the screen goes empty for half a
     second. Delay the hide until the fade has finished. */
  transition: opacity 200ms ease, visibility 0s linear 200ms;
}

/* SEQUENCED, not cross-faded. Every beat is inset:0, so while both were fading at once the
   viewer saw two sets of words at ~50% each, stacked — which on a phone, where the copy fills
   the screen, reads as the layout breaking rather than as a dissolve. The outgoing one now
   leaves first and the incoming waits for it. Total is 580ms against the old 520, so it costs
   almost nothing in pace. --enter is the wait; the deal animation below has to honour it too,
   or a third of the deal would play while the beat is still invisible. */
.beat.is-active {
  opacity: 1;
  visibility: visible;
  transition: opacity 380ms ease var(--enter), visibility 0s linear var(--enter);
}

.stage {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(1rem, 3vw, 3rem);
  min-height: 0;
  flex: 0 1 auto;
}

.stage--row  { flex-wrap: wrap; gap: clamp(2rem, 7vw, 6rem); }
.stage--pair { gap: clamp(1.2rem, 4vw, 4rem); }

/* a `lean` stage grows slightly on its later phases, when the copy asks her to look closer */
.stage--lean { transition: transform 720ms cubic-bezier(.22, .8, .3, 1); }
.beat[data-at="1"] .stage--lean { transform: scale(1.07); }

/* A card that joins on a later phase. It sits out the deal AND takes no width until its cue,
   so the earlier cards stay centred instead of hugging one side of a reserved gap; the row
   then widens and re-centres as it arrives. */
/* `.card.card--late`, not `.card--late`: this rule sits ABOVE `.card` in the file, and at equal
   specificity source order would let .card's width win. Doubling the class settles it. */
.card.card--late {
  width: 0;
  margin-left: calc(-1 * clamp(.6rem, 1.8vw, 1.8rem));   /* swallow the flex gap too */
  opacity: 0;
  overflow: hidden;
  transition: width 640ms cubic-bezier(.22, .8, .3, 1),
              margin-left 640ms cubic-bezier(.22, .8, .3, 1),
              opacity 520ms ease 200ms;
}

.card.card--late.is-shown {
  width: calc(var(--h) * 822 / 1122);
  margin-left: 0;
  opacity: 1;
}

/* The exit, and the exact inverse of .card--late: it collapses its own width so the cards that
   remain slide together and re-centre, instead of leaving a hole where it stood. */
.card.card--exit.is-gone {
  width: 0;
  margin-left: calc(-1 * clamp(.6rem, 1.8vw, 1.8rem));
  opacity: 0;
  overflow: hidden;
  transition: width 640ms cubic-bezier(.22, .8, .3, 1),
              margin-left 640ms cubic-bezier(.22, .8, .3, 1),
              opacity 380ms ease;
}

/* A stacked stage: every card sits in the SAME grid cell, so a replacement cross-fades in place
   instead of sliding. Overrides the width animation above — here the footprint never changes,
   only opacity, which is the whole point. */
.stage--stack { display: grid; place-items: center; }
.stage--stack > .card { grid-area: 1 / 1; }

.stage--stack .card.card--late,
.stage--stack .card.card--exit.is-gone {
  width: calc(var(--h) * 822 / 1122);   /* hold the slot — no collapse, no re-centring */
  margin-left: 0;
  opacity: 0;
  pointer-events: none;                 /* an invisible card must not swallow the click */
  transition: opacity 620ms ease;
}

.stage--stack .card.card--late.is-shown { opacity: 1; pointer-events: auto; }

/* the wardrobe beat: a court and its suit's two colours, side by side */
.stage--wardrobe { flex-wrap: nowrap; gap: clamp(1.4rem, 4vw, 3.4rem); align-items: center; }
.stage--wardrobe .card { --h: min(56vh, 460px); }

/* ---- the deck's palette -------------------------------------------------------------------- */

.stage--palette { flex-wrap: nowrap; gap: clamp(.5rem, 1.6vw, 1.4rem); }

.swatch { margin: 0; display: flex; flex-direction: column; align-items: center; gap: .8rem; }

/* a candidate palette: its colours stacked as one column, so four of them read as four
   alternatives rather than twenty-four loose chips */
.stage--tones { gap: clamp(1rem, 2.6vw, 2.4rem); }

.tone-strip {
  display: flex;
  flex-direction: column;
  width: clamp(58px, 8vw, 108px);
  border-radius: 6px;
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(227, 215, 178, .2), 0 12px 30px rgba(8, 5, 2, .55);
}

.tone-strip__band { height: clamp(22px, 3.4vh, 34px); }

.swatch__chip {
  width: clamp(72px, 9.5vw, 132px);
  height: clamp(78px, 14vh, 136px);
  border-radius: 6px;
  box-shadow: inset 0 0 0 1px rgba(227, 215, 178, .22), 0 12px 30px rgba(8, 5, 2, .55);
}

/* A PAIR: the second colour GROWS OUT OF the first rather than sitting beside it — one block of
   the suit colour with the second rising from inside its lower edge. That is what the emblems
   actually do (the eagle sits ON the red field, the stone IN the gold band), and it stops the
   pair reading as two equal colours. */
.swatch__pair {
  position: relative;
  border-radius: 6px;
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(227, 215, 178, .22), 0 12px 30px rgba(8, 5, 2, .55);
}

.swatch__pair .swatch__chip {
  width: clamp(72px, 9.5vw, 132px);
  border-radius: 0;
  box-shadow: none;
}

.swatch__pair .swatch__chip--second {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  width: 62%;
  height: 42%;
  border-radius: 999px 999px 0 0;
}

/* the emblem sits above its own two colours — small, since the chips are the subject here.
   `.emblem.swatch__emblem`, not `.swatch__emblem`: .emblem is defined LATER in this file, so at
   equal specificity source order would hand it the win and the badge would render hero-sized.
   Same reason `.card.card--late` doubles its class. */
.emblem.swatch__emblem {
  width: clamp(54px, 7vw, 92px);
  height: clamp(58px, 8.5vh, 92px);
  object-fit: contain;
  filter: drop-shadow(0 6px 16px rgba(8, 5, 2, .6));
}

/* the two tone names under the suit — what the cape is, and what the tunic is */
.swatch__tones {
  margin: -.35rem 0 0;
  font-size: clamp(.6rem, .95vw, .78rem);
  font-style: italic;
  color: rgba(168, 148, 107, .78);
  white-space: nowrap;
}

.swatch__name {
  font-size: clamp(.62rem, 1vw, .82rem);
  text-transform: uppercase;
  letter-spacing: .18em;
  color: var(--parch-soft);
  white-space: nowrap;
}

/* ---- the pipeline specimen ---------------------------------------------------------------- */

/* One figure and the card it becomes, treated per stage so she can SEE what each step does.
   The treatments are illustrative — the real intermediates were never kept as a matched set. */
.specimen {
  position: relative;
  display: grid;
  /* The row must be DEFINITE or percentage heights on the layers resolve against nothing and are
     dropped — which is why the landscape export sheet sized itself by width alone and spilled
     over the rail. `place-items: center` alone leaves the row auto. */
  grid-template-rows: minmax(0, 1fr);
  place-items: center;
  height: clamp(200px, 40vh, 380px);
  width: calc(clamp(200px, 40vh, 380px) * 822 / 1122);
}

.specimen__layer {
  grid-area: 1 / 1;
  /* max-*, so the element hugs its image — the checker backdrop and the card shadow belong to
     the picture, not the box. These only work because .specimen's row is DEFINITE above; with an
     auto row the percentages resolve against nothing and are dropped, which is what let the
     landscape export sheet size itself by width alone and spill over the rail. */
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  opacity: 0;
  /* All six share one grid cell, and opacity 0 does NOT stop an element taking clicks — the last
     layer in DOM order sits on top of the stack and would swallow every click on the specimen,
     whichever stage is showing. Same rule, same reason, as .stage--stack's hidden cards. */
  pointer-events: none;
  transition: opacity 560ms ease, filter 620ms ease;
}

/* the checker shows through the alpha — "the background is gone", said without a caption */
.specimen__layer--0,
.specimen__layer--1,
.specimen__layer--2,
.specimen__layer--3 {
  background-image:
    linear-gradient(45deg, rgba(227, 215, 178, .10) 25%, transparent 25% 75%, rgba(227, 215, 178, .10) 75%),
    linear-gradient(45deg, rgba(227, 215, 178, .10) 25%, transparent 25% 75%, rgba(227, 215, 178, .10) 75%);
  background-size: 22px 22px;
  background-position: 0 0, 11px 11px;
}

/* The export stage is a SHEET, not a card — it needs the width of its column. The grid columns
   are fixed, so widening here moves the art only; the text beside it does not shift. */
.beat[data-forge-at="5"] .specimen { width: min(100%, 560px); }

.specimen__layer--4,
.specimen__layer--5 {
  border-radius: 9px;
  box-shadow: 0 0 0 1px rgba(227, 215, 178, .16), 0 22px 52px rgba(8, 5, 2, .62);
}

/* stage -> layer. 0 extract · 1 trace (extract + treatment) · 2 harmonize · 3 recolor ·
   4 hero-assemble · 5 export */
/* one layer per stage, in order — trace included now that it has its own artifact */
.beat[data-forge-at="0"] .specimen__layer--0,
.beat[data-forge-at="1"] .specimen__layer--1,
.beat[data-forge-at="2"] .specimen__layer--2,
.beat[data-forge-at="3"] .specimen__layer--3,
.beat[data-forge-at="4"] .specimen__layer--4,
.beat[data-forge-at="5"] .specimen__layer--5 { opacity: 1; pointer-events: auto; }

/* ---- the prompt, beside the card it made ------------------------------------------------- */

.stage--prompt { flex-wrap: nowrap; gap: clamp(1.2rem, 3vw, 3rem); align-items: center; }
/* smaller than a normal card — here it is the exhibit, and the prompt needs the width */
.stage--prompt .card { --h: clamp(170px, 34vh, 300px); }

/* Deliberately small: it should read as a WALL of instruction first and words second — that
   contrast against the card beside it is the whole point of the beat. */
/* It has to be READABLE — she is meant to actually read the negatives, not admire a texture.
   The whole prompt still fits (no max-height: clipping it would cut the second phase's point),
   so the card shrinks instead and the wall gets the room. */
.prompt {
  margin: 0;
  font-family: var(--mono);
  font-size: clamp(.62rem, 1.02vw, .8rem);
  line-height: 1.6;
  text-align: left;
  white-space: pre-wrap;
  color: var(--parchment);
  /* WIDE on purpose: a narrow column ran to 24 lines and pushed the beat past the top of the
     screen. `flex: none` stops the row shrinking it back below this. */
  max-width: 76ch;
  flex: none;
}

/* The flags arrive on the phase that names them. Hidden with OPACITY, never display:none —
   the text keeps its space, so the wall does not reflow and the prose above it never moves. */
.prompt__flags {
  color: var(--shields);
  opacity: 0;
  transition: opacity 760ms ease;
}

.beat[data-at="1"] .prompt__flags { opacity: 1; }

/* The lightbox lives OUTSIDE any .beat, so the reveal rule above can never reach it. Without
   this the flags are copied into the zoom and then rendered at opacity 0 — present in the DOM,
   invisible on screen. swapZoom has already stripped them if the phase hadn't revealed them,
   so anything that reaches here is meant to be seen. */
.lightbox__text .prompt__flags { opacity: 1; }

/* ---- the four ways a hand scores --------------------------------------------------------- */

/* flex-START, not end: bottom-aligning the figures let a caption that wraps to two lines shove
   its own art upward, so the four arts sat at three different heights. */
.stage--scores { flex-wrap: nowrap; gap: clamp(1rem, 3.5vw, 3.4rem); align-items: flex-start; }

/* Tiles arrive one per phase via [data-show-at], so unlike a late CARD they must hold their
   width from the start — otherwise naming the second way would shove the first one sideways. */
.score {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* was clamp(.7rem, 1.6vh, 1.2rem) — 14px, which read as the caption touching the art */
  gap: clamp(1.4rem, 3.6vh, 2.4rem);
  opacity: 0;
  transform: translateY(14px);
  /* --i staggers the four so they land left-to-right, in the order the sub-line names them */
  transition: opacity 560ms ease calc(var(--i, 0) * 130ms),
              transform 560ms cubic-bezier(.22, .8, .3, 1) calc(var(--i, 0) * 130ms);
}

.score.is-shown { opacity: 1; transform: none; }

/* The four tiles share a caption baseline because the art BOX is the same height for all of them.
   But the art inside filled that box by wildly different amounts — the cards 100%, the coins 46%,
   the numeral somewhere between — so the gap from actual ink to caption ran 2px to 60px and the
   two card tiles read as touching their labels. Bottom-aligning gives the four a common floor, so
   one gap value produces one visible gap. (PM 2026-07-30) */
.score__art {
  --art-h: clamp(96px, 20vh, 200px);
  position: relative;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  height: var(--art-h);
}

/* The fan is rotated about `bottom center`, which swings its outer corners ~7% of the art height
   BELOW the box the caption measures from — it was the one actually overlapping, at 2px clear.
   Lift it with a transform, never a margin: a margin would push its caption off the shared
   baseline the other three sit on. */
.score--fan .score__art { transform: translateY(calc(var(--art-h) * -.07)); }

/* Matches .chip figcaption / .map figcaption exactly — every caption in the deck is set the
   same way, and an italic lowercase one here read as a different kind of label. */
.score__name {
  font-size: clamp(.85rem, 1.3vw, 1.1rem);
  text-transform: uppercase;
  letter-spacing: .26em;
  color: var(--parch-soft);
  text-align: center;
  min-height: 2.6em;          /* reserve the second line so the beat does not resize per phase */
}

.score__card {
  height: 100%;
  width: auto;
  border-radius: 7px;
  box-shadow: 0 0 0 1px rgba(227, 215, 178, .16), 0 14px 34px rgba(8, 5, 2, .6);
}

/* the fan: three backs overlapped and splayed, so "most cards" reads as a quantity */
.score--fan .score__art > * { margin-left: -34%; transform-origin: bottom center; }
.score--fan .score__art > :first-child { margin-left: 0; transform: rotate(-11deg); }
.score--fan .score__art > :last-child  { transform: rotate(11deg); }

.score__coin { height: 46%; width: auto; }
.score--coins .score__art { gap: 6%; }

.score__num {
  font: 400 clamp(4rem, 12vh, 8.5rem)/1 var(--serif);
  color: var(--rings);
  text-shadow: 0 0 46px rgba(217, 178, 76, .35);
}

/* 2–4 cards in a row (the knights, the before/after reversals) — tighter gap than .stage--row,
   and sized down so four 822x1122 cards still fit a laptop without overflowing */
.stage--many { flex-wrap: nowrap; gap: clamp(.6rem, 1.8vw, 1.8rem); }
.stage--many .card { --h: clamp(150px, 34vh, 340px); }

/* ---- cards — big ------------------------------------------------------------------------ */

/* Sized by HEIGHT, not width: vertical space is what runs out, and the 822:1122 aspect turns
   a generous width into an overflowing height. Width is derived from the height. */
.card {
  --h: clamp(230px, 56vh, 560px);
  height: var(--h);
  width: calc(var(--h) * 822 / 1122);
  perspective: 1800px;
  flex: none;
}

.stage--pair .card { --h: clamp(190px, 50vh, 500px); }
.card--zoom       { --h: clamp(240px, 58vh, 600px); }

.card__inner {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 860ms cubic-bezier(.4, .04, .2, 1);
}

.card.is-revealed .card__inner { transform: rotateY(180deg); }

/* Re-entering a beat has to turn an already-flipped card back over. Without this the browser
   ANIMATES rotateY(180deg)->0, so the card appeared face up and rotated face DOWN before the
   deal replayed. Applied for one frame while the state is reset, then removed so the real
   flip still animates. */
.card.is-resetting .card__inner { transition: none; }

/* On a dark table a card is a lit object: a warm rim, a soft bloom, a deep drop. */
.card__face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  border-radius: 11px;
  background: var(--card) center/cover no-repeat;
  box-shadow:
    0 0 0 1px rgba(227, 215, 178, .16),
    0 2px 5px rgba(8, 5, 2, .70),
    0 26px 60px rgba(8, 5, 2, .62),
    0 0 90px rgba(217, 178, 76, .10);
}

.card__face--front { transform: rotateY(180deg); }

.card--img {
  height: clamp(230px, 54vh, 540px);
  width: auto;
  border-radius: 11px;
  box-shadow:
    0 0 0 1px rgba(227, 215, 178, .16),
    0 26px 60px rgba(8, 5, 2, .62);
}

.beat.is-active .deal-in { animation: deal var(--deal) var(--enter) both; }

@keyframes deal {
  from { transform: translateY(64vh) rotate(-10deg) scale(.82); opacity: 0; }
  to   { transform: translateY(0)    rotate(0)      scale(1);   opacity: 1; }
}

.stage--pair .deal-in:nth-child(2) { animation-delay: calc(var(--enter) + 170ms); }
.stage--row  .deal-in:nth-child(2) { animation-delay: calc(var(--enter) + 130ms); }
.stage--row  .deal-in:nth-child(3) { animation-delay: calc(var(--enter) + 260ms); }
.stage--row  .deal-in:nth-child(4) { animation-delay: calc(var(--enter) + 390ms); }

/* ---- emblems ---------------------------------------------------------------------------- */

/* Native aspects run 0.66 (cups, tall+narrow) to 1.13 (rings, wide+short). Sizing by width
   made the chalice ~1.7x taller than the ring. Each emblem gets an IDENTICAL box and is fitted
   inside it, so their visual footprints match regardless of proportion. */
.emblem {
  width: clamp(120px, 15vw, 250px);
  height: clamp(150px, 27vh, 290px);
  object-fit: contain;
  filter: drop-shadow(0 10px 26px rgba(8, 5, 2, .75));
}

/* Not the subject of this beat. Held back rather than hidden — they still have to be legible as
   "a single object" for the comparison to work, they just stop competing with the one that is. */
/* Dimmed through FILTER, not `opacity` — these carry `deal-in`, whose fill-mode is `both`, so its
   final keyframe (opacity:1) is fill-forwards and outranks any declared opacity. Same trap that
   stopped dealt cards fading out. `opacity()` inside filter is untouched by the animation. */
.emblem--dim {
  filter: opacity(.4) saturate(.55) drop-shadow(0 10px 26px rgba(8, 5, 2, .5));
}

/* a backlight so the darker emblems (the blue chalice especially) hold on a dark ground */
.emblem--hero {
  width: clamp(260px, 40vw, 620px);
  height: clamp(280px, 56vh, 620px);
  filter: drop-shadow(0 14px 40px rgba(8, 5, 2, .8));
}

.stage:has(.emblem--hero) {
  background: radial-gradient(circle at 50% 50%,
    color-mix(in srgb, var(--accent) 26%, transparent) 0%, transparent 62%);
}

/* ---- the traditional Sicilian semi ------------------------------------------------------ */

.stage--semi { gap: clamp(1.4rem, 5vw, 4rem); align-items: flex-end; }

.stage--semi figure {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .9rem;
}

/* Their native aspect ratios are wildly unequal (denari 371×374, spade 259×735), so each gets
   an identical box and is fitted inside it. Height-matching alone would make coins read huge. */
.semi {
  width: clamp(80px, 11vw, 165px);
  height: clamp(165px, 32vh, 320px);
  object-fit: contain;
  filter: drop-shadow(0 8px 22px rgba(8, 5, 2, .7));
}

.stage--semi figcaption {
  font-size: clamp(.9rem, 1.4vw, 1.2rem);
  text-transform: uppercase;
  letter-spacing: .26em;
  color: var(--parch-soft);
}

/* a photographed/flat plate (the real family crest) — framed, unlike the cut-out emblems */
.plate {
  height: clamp(200px, 46vh, 460px);
  width: auto;
  border-radius: 4px;
  box-shadow:
    0 0 0 1px rgba(227, 215, 178, .18),
    0 24px 56px rgba(8, 5, 2, .66);
}

/* the two rejected charges: they carry their own cream ground, so frame them as swatches
   rather than pretending they are cut-outs like the locked eagle */
.plate--small { height: clamp(150px, 30vh, 300px); border-radius: 6px; }

/* the uprighted donkey-head crop is portrait (720x1070) — no frame, it's a detail not an object */
.plate--tall {
  height: clamp(200px, 50vh, 480px);
  border-radius: 4px;
  box-shadow: 0 0 0 1px rgba(227, 215, 178, .14), 0 20px 48px rgba(8, 5, 2, .6);
}

/* ---- the Italian → French suit map ------------------------------------------------------- */

.stage--map { gap: clamp(1.6rem, 6vw, 5rem); align-items: flex-start; }

.map {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(.4rem, 1.4vh, 1rem);
}

.map .semi { height: clamp(110px, 22vh, 220px); width: clamp(60px, 8vw, 120px); }

.map figcaption {
  font-size: clamp(.85rem, 1.35vw, 1.15rem);
  text-transform: uppercase;
  letter-spacing: .26em;
  color: var(--parch-soft);
}

/* THE CHAIN, revealed a link at a time. The whole column climbs as the chain grows below it,
   so the group stays optically centered: phase 0 sits lowest, phase 2 highest. */
/* Move the WHOLE column, not its children — transforming the caption alone slid it on top of
   the German word underneath it. */
.map {
  transition: transform 720ms cubic-bezier(.22, .8, .3, 1);
  transform: translateY(9.6vh);
}

.beat[data-at="1"] .map { transform: translateY(6.4vh); }
.beat[data-at="2"] .map { transform: translateY(3.2vh); }
.beat[data-at="3"] .map { transform: translateY(0); }

/* each link of the chain fades in on its own phase: German word, French pip, English name */
.map .ger,
.map .pip,
.map .eng {
  opacity: 0;
  transform: translateY(14px);
  /* The 240ms delay was tuned when beats and phases cross-faded — it let the link appear
     while the copy was mid-dissolve. Now that the copy LEAVES first, a fixed 240ms starts
     the reveal against text that is still going, which is the overlap left on #suit-map
     and #ours-doesnt. Rebased so the art moves with the arriving copy, not the leaving. */
  transition: opacity 620ms ease var(--enter), transform 620ms cubic-bezier(.22, .8, .3, 1) var(--enter);
}

.map .ger,
.map .eng {
  font-size: clamp(.85rem, 1.35vw, 1.15rem);
  text-transform: uppercase;
  letter-spacing: .26em;
}

.map .ger { color: var(--rings); }
.map .eng { color: var(--card); }        /* the end of the chain — the deck she owns */

.beat[data-at="1"] .map .ger,
.beat[data-at="2"] .map .ger,
.beat[data-at="3"] .map .ger,
.beat[data-at="2"] .map .pip,
.beat[data-at="3"] .map .pip,
.beat[data-at="3"] .map .eng { opacity: 1; transform: translateY(0); }

/* the French pips she already knows, in card colors */
.pip {
  font-size: clamp(2.2rem, 5vw, 4.4rem);
  line-height: 1;
  color: var(--parchment);
  text-shadow: 0 4px 18px rgba(8, 5, 2, .7);
}

.pip--red { color: var(--shields); }

/* ---- suit color chips ------------------------------------------------------------------ */

.stage--chips { gap: clamp(1.4rem, 5vw, 4.5rem); align-items: flex-end; }

/* Two phases: the four emblems alone, then their colors named beneath. The whole column moves
   as a unit (moving children individually made the map's captions collide). */
.chip {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .8rem;
  transition: transform 720ms cubic-bezier(.22, .8, .3, 1);
  transform: translateY(4.4vh);
}

.beat[data-at="1"] .chip { transform: translateY(2.2vh); }
.beat[data-at="2"] .chip { transform: translateY(0); }

/* phase 2 = the Sicilian suit each one replaces · phase 3 = its color */
.chip__was,
.chip__bar,
.chip figcaption {
  opacity: 0;
  transform: translateY(14px);
  /* The 240ms delay was tuned when beats and phases cross-faded — it let the link appear
     while the copy was mid-dissolve. Now that the copy LEAVES first, a fixed 240ms starts
     the reveal against text that is still going, which is the overlap left on #suit-map
     and #ours-doesnt. Rebased so the art moves with the arriving copy, not the leaving. */
  transition: opacity 620ms ease var(--enter), transform 620ms cubic-bezier(.22, .8, .3, 1) var(--enter);
}

.chip__was {
  font-size: clamp(.8rem, 1.25vw, 1.05rem);
  text-transform: uppercase;
  letter-spacing: .26em;
  color: var(--parch-soft);
  text-align: center;
  line-height: 1.5;
}

/* The French name under the Italian one, in the same lighter ink the map beat ends its chain
   with — the two labels are one stack, so they rise together on their phase. */
.chip__fr {
  display: block;
  color: var(--card);
}

.beat[data-at="1"] .chip__was,
.beat[data-at="2"] .chip__was,
.beat[data-at="2"] .chip__bar,
.beat[data-at="2"] .chip figcaption { opacity: 1; transform: translateY(0); }

.chip .emblem { width: clamp(90px, 11vw, 190px); height: clamp(110px, 20vh, 220px); }

.chip__bar {
  width: clamp(64px, 8vw, 130px);
  height: clamp(12px, 1.6vh, 20px);
  border-radius: 3px;
  background: var(--fill);
  box-shadow: 0 0 0 1px rgba(227, 215, 178, .22), 0 6px 16px rgba(8, 5, 2, .55);
}

.chip figcaption {
  font-size: clamp(.85rem, 1.3vw, 1.1rem);
  text-transform: uppercase;
  letter-spacing: .26em;
  color: var(--parch-soft);
}

/* ---- the Act Two timeline ---------------------------------------------------------------- */

.stage--chart { width: min(92vw, 1100px); }

.chart {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  /* The gap was tuned when the chart held 30 bars. At 43 it ate HALF the chart — 43 bars of 8px
     against 42 gaps of 8.3px — and the bars read as hairlines. Tightening it is the cheapest
     width there is: the chart itself cannot grow without breaking the fixed art column that
     holds the text still. (PM 2026-07-30) */
  gap: clamp(1px, .28vw, 4px);
  width: 100%;
  height: clamp(130px, 30vh, 300px);
}

/* one bar per working day; --h is that day's share of the peak (46 entries, 25 July) */
.bar {
  flex: 1 1 0;
  min-width: 4px;
  /* Floor raised from 3px: heights are relative to the busiest day, and that day went from 46
     entries to 73 when the chart gained the first three weeks — so every other bar lost about a
     third of its height and the one-entry days fell to a 3px sliver.
     RULED 2026-07-30: a floor, never a rescale. Square-root or log scaling would make the middle
     of the chart far more readable, and it was offered and DECLINED — on a linear scale alone is
     "the tall ones are the days I couldn't leave it alone" literally true, and the caption is
     what the chart is for. Do not re-propose it. */
  height: max(6px, calc(var(--h) * 100%));
  border-radius: 2px 2px 0 0;
  background: rgba(227, 215, 178, .17);
  transition: background 260ms ease, transform 260ms ease;
  transform-origin: bottom;
}

.bar.is-past { background: rgba(217, 178, 76, .34); }

/* A bar with something to say. Only these respond to a click, so only these are allowed to look
   like they would — a chart where every bar invites a click and two thirds do nothing is worse
   than one that never invited it. The cue is deliberately quiet: it should reward a poke, not
   compete with the milestone the stepper is on. */
.bar--said {
  cursor: pointer;
  background: rgba(227, 215, 178, .34);
}

.bar--said:hover { background: rgba(217, 178, 76, .6); }
.bar--open       { background: var(--rings); }

/* the tile a clicked bar opens — anchored under the chart, positioned by story.js */
.stage--chart { position: relative; }

.daynote {
  position: absolute;
  top: calc(100% + .9rem);
  width: min(38ch, 62%);
  padding: .85rem 1rem 1rem;
  border-radius: 8px;
  border: 1px solid rgba(217, 178, 76, .3);
  background: rgba(24, 15, 7, .96);
  box-shadow: 0 18px 44px rgba(8, 5, 2, .6);
  text-align: left;
  z-index: 5;
}

.daynote__when {
  margin: 0 0 .35rem;
  font-family: var(--mono);
  font-size: clamp(.7rem, 1.1vw, .85rem);
  letter-spacing: .04em;
  color: var(--rings);
}

.daynote__what {
  margin: 0;
  font-size: clamp(.9rem, 1.35vw, 1.05rem);
  line-height: 1.45;
  color: var(--parchment);
}

.bar.is-here {
  background: var(--rings);
  transform: scaleX(1.5);
  box-shadow: 0 0 18px rgba(217, 178, 76, .5);
}

.lede--mile {
  font-size: clamp(1.35rem, 2.5vw, 2.3rem);
  line-height: 1.2;
  max-width: 24ch;
}

/* the raw log heading — evidence, deliberately quieter than the caption above it */
.stamp--log {
  opacity: .5;
  line-height: 1.45;
  max-width: 46ch;
  font-size: clamp(.62rem, 1.05vw, .8rem);
}

/* ---- the machine: scale numbers + pipeline rail ------------------------------------------ */

.stage--stats {
  display: grid;
  grid-template-columns: repeat(2, auto);
  gap: clamp(1.4rem, 4vh, 3rem) clamp(2rem, 6vw, 5rem);
}

/* Three counters, not the forge's four — the 2-column grid would strand the third on its own
   row. One column reads as a ledger standing beside the copy. */
.stage--craft { grid-template-columns: auto; gap: clamp(1.1rem, 3.2vh, 2.2rem); }

.stat { display: flex; flex-direction: column; align-items: center; gap: .35rem; }

.stat b {
  font-weight: 400;
  font-size: clamp(2.2rem, 5vw, 4.4rem);
  line-height: 1;
  color: var(--rings);
  font-variant-numeric: oldstyle-nums;
}

.stat span {
  font-size: clamp(.68rem, 1.1vw, .88rem);
  text-transform: uppercase;
  letter-spacing: .22em;
  color: var(--parch-soft);
}

/* COLUMN: the specimen sits above its rail, so the beat stays two columns (art | copy) instead
   of spreading into three. */
.stage--pipe {
  flex-direction: column;
  gap: clamp(1rem, 2.6vh, 2rem);
  width: min(46vw, 520px);
}

.pipeline {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: clamp(.35rem, 1vw, .9rem);
}

.pipe {
  font-family: var(--mono);
  font-size: clamp(.7rem, 1.25vw, 1rem);
  letter-spacing: .04em;
  padding: .5em .8em;
  border-radius: 5px;
  border: 1px solid rgba(227, 215, 178, .18);
  color: var(--parch-soft);
  transition: all 280ms ease;
}

.pipe.is-past { color: rgba(217, 178, 76, .7); border-color: rgba(217, 178, 76, .3); }

.pipe.is-here {
  color: var(--table);
  background: var(--rings);
  border-color: var(--rings);
  box-shadow: 0 0 26px rgba(217, 178, 76, .45);
}

.pipe__arrow { color: rgba(227, 215, 178, .28); font-size: .85em; }

/* ---- type — big ------------------------------------------------------------------------- */

.lede {
  margin: 0;
  font-size: clamp(2.2rem, 5vw, 4.6rem);
  line-height: 1.1;
  letter-spacing: -.015em;
  max-width: 20ch;
  color: var(--card);
  text-shadow: 0 2px 26px rgba(8, 5, 2, .6);
}

.lede--alone { max-width: 17ch; font-size: clamp(2.6rem, 6.4vw, 5.6rem); }

.lede--code {
  font-family: var(--mono);
  font-size: clamp(1rem, 3vw, 2.2rem);
  letter-spacing: -.02em;
  color: var(--shields);
  max-width: none;
  word-break: break-all;
}

.sub {
  margin: 0;
  /* floor was 1.2rem/19px — it only binds under ~1030px wide, which is exactly the narrow-laptop
     case the "a little small" reports came from */
  font-size: clamp(1.35rem, 2.1vw, 1.75rem);
  line-height: 1.5;
  color: var(--sub-ink);
  max-width: 40ch;
  font-style: italic;
}

/* Author emphasis: `*like this*` in beats.js. Lifted to the full parchment as well as bolded —
   weight alone is nearly invisible in an italic serif on a dark ground. */
.sub strong, .lede strong { color: var(--parchment); font-weight: 700; }

.whisper {
  margin: 0;
  font-size: clamp(.95rem, 1.6vw, 1.35rem);
  text-transform: uppercase;
  letter-spacing: .38em;
  color: var(--accent);
  font-style: normal;
}

.tally { font-size: 1.6em; color: var(--rings); font-variant-numeric: oldstyle-nums; }

.stamp {
  margin: 0;
  font-family: var(--mono);
  font-size: clamp(.85rem, 1.5vw, 1.15rem);
  letter-spacing: .04em;
  color: var(--parch-soft);
  min-height: 1.2em;
}

/* ---- the version stepper (keyboard-first) ----------------------------------------------- */

.steps {
  display: flex;
  gap: 7px;
  align-items: center;
  justify-content: center;
}

.step {
  width: 26px;
  height: 4px;
  border-radius: 2px;
  background: rgba(227, 215, 178, .22);
  transition: background 220ms ease, transform 220ms ease;
}

.step.is-here { background: var(--rings); transform: scaleY(2.2); }
.step.is-past { background: rgba(217, 178, 76, .45); }

/* ---- chrome ----------------------------------------------------------------------------- */

.hint {
  position: fixed;
  left: 50%;
  bottom: 1.4rem;
  transform: translateX(-50%);
  margin: 0;
  z-index: 4;
  font-size: .68rem;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--parch-soft);
  opacity: 0;
  transition: opacity 700ms ease;
  pointer-events: none;
  white-space: nowrap;
}

.hint.is-shown { opacity: .34; }

/* ---- the lightbox ------------------------------------------------------------------------ */

.zoomable { cursor: zoom-in; }

/* must beat the .lightbox rule below, or [hidden] is ignored — the same trap .index hit */
.lightbox[hidden] { display: none; }

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 9;               /* above the grain (3), the hint (4) and the index (5) */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2.5vmin;
  background: rgba(14, 9, 4, .95);
  cursor: zoom-out;
}

/* The stack scales in ONCE per opening. The images inside only ever crossfade, so stepping
   between cards never replays the zoom or flashes an undecoded frame. */
.lightbox__stack {
  position: relative;
  width: 96vw;
  height: 95vh;
}

/* The text exhibit. Sized to READ — a comfortable measure, not the wall it is on the beat.
   Scrolls if the viewport is short rather than clipping, since the tail is the point. */
.lightbox__text { display: none; }

.lightbox.is-text .lightbox__stack { display: none; }

/* Opaque for text. At .95 the beat ghosted through and competed with the words — tolerable
   behind a photograph that covers the screen, unreadable behind type. */
.lightbox.is-text { background: var(--table-rim); }
.lightbox.is-text .lightbox__text {
  display: block;
  margin: 0;
  max-width: min(86ch, 92vw);
  max-height: 92vh;
  overflow-y: auto;
  font-family: var(--mono);
  font-size: clamp(.82rem, 1.5vw, 1.15rem);
  line-height: 1.85;
  white-space: pre-wrap;
  text-align: left;
  color: var(--parchment);
  cursor: auto;                    /* it is being read, not dismissed by accident */
}

.lightbox:not([hidden]) .lightbox__stack {
  animation: zoom-in 340ms cubic-bezier(.16, .84, .32, 1) both;
}

/* Two layers that CROSSFADE — no slide. This is an inspection mode, not a carousel: she is
   looking at one card, then another, and movement would compete with the art. The two layers
   exist so a swap never shows an undecoded frame. */
.lightbox img {
  position: absolute;
  inset: 0;
  margin: auto;                 /* centers an auto-sized absolute element */
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  border-radius: 10px;
  box-shadow:
    0 0 0 1px rgba(227, 215, 178, .2),
    0 40px 90px rgba(8, 5, 2, .85);
  opacity: 0;
  transition: opacity 260ms ease;
  will-change: opacity;
}

.lightbox img.is-front { opacity: 1; }

@keyframes zoom-in {
  from { transform: scale(.88); opacity: 0; }
  to   { transform: scale(1);   opacity: 1; }
}

.index[hidden] { display: none; }

.index {
  position: fixed;
  inset: 0;
  z-index: 5;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: .25rem;
  align-content: center;
  padding: 6vmin;
  background: rgba(14, 9, 4, .96);
}

.index button {
  font: inherit;
  font-size: .95rem;
  text-align: left;
  padding: .55rem .8rem;
  border: 0;
  border-left: 2px solid transparent;
  border-radius: 3px;
  background: none;
  color: var(--parch-soft);
  cursor: pointer;
}

.index button:hover { background: rgba(227, 215, 178, .08); color: var(--card); }
.index button.is-here { border-left-color: var(--rings); color: var(--card); }

/* ---- copy block + the wide-screen side-by-side ------------------------------------------ */

.copy {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(.9rem, 2.2vh, 1.8rem);
  min-width: 0;
}

/* the phases stack in ONE grid cell so the copy crossfades in place instead of the block
   jumping height as the text swaps */
.phases { display: grid; }

.phase {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  /* NOT `inherit` — the parent is a grid with no align-items, so inherit resolves to stretch
     and a max-width'd .sub gets pinned to the left instead of centered. */
  align-items: center;
  gap: clamp(.9rem, 2.2vh, 1.8rem);
  opacity: 0;
  /* Sequenced, exactly as the beat transition is: phases share one grid cell, so cross-fading
     them put two paragraphs on screen at ~50% each, stacked. This is the leaving half. */
  transition: opacity 180ms ease;
  pointer-events: none;
}

/* ...and the arriving half waits for it, on the same --enter the beats use. */
.phase.is-on {
  opacity: 1;
  pointer-events: auto;
  transition: opacity 320ms ease var(--enter);
}

/* The same trick as .phases, for the two beats whose copy is written by JS rather than authored
   as phases: #forge-pipeline and #timeline. Setting `textContent` on ONE element re-flows the
   beat every step — a two-line caption after a four-line one moves everything under it, so
   stepping the machine or the chart made the page jump. Stack every variant in one grid cell
   instead and cross-fade: the box is permanently as tall as its tallest member, so nothing can
   shift. Costs the DOM eight extra paragraphs. */
.swap { display: grid; }
.swap > * { grid-area: 1 / 1; transition: opacity 180ms ease; }
/* Hide the ones that are OFF rather than lighting the one that is on. `.swap > .is-on {opacity:1}`
   would outrank .stamp--log's deliberate .5 on specificity and print the log receipt at full
   strength; this way a visible variant simply keeps whatever opacity it was designed with. */
.swap > *:not(.is-on) { opacity: 0; }
/* Sequenced like the beats and the phases: the arriving caption waits for the leaving one. Only
   the TIMING is set here, never the opacity, for the specificity reason above. */
.swap > .is-on { transition: opacity 320ms ease var(--enter); }

/* On a landscape screen, stacking art over text wastes the horizontal axis and caps how big
   the art can be. Beside it, the cards get most of the viewport height. Rows of emblems are
   already horizontal, so they stay centered. */
@media (min-aspect-ratio: 5/4) {
  /* TWO FIXED COLUMNS, not a centred pair. As a flex row the art and the words were centred as one
     unit, so the text's left edge was pushed around by BOTH the art's width (0 to 895px across the
     deck) and the copy block's own width (it shrinks to its content, 255 to 514px). The text
     started anywhere from x=537 to x=1013 — 476px — and jumped 411px between reversal-engraved and
     graveyard. Fixed columns pin the text's left edge to one value at every viewport size, and give
     the art a stable centre line to sit on. (PM 2026-07-30) */
  .beat:has(.stage:not(.stage--row)) {
    display: grid;
    grid-template-columns: min(46vw, 700px) min(34rem, 34vw);
    justify-content: center;
    align-items: center;
    gap: clamp(2rem, 5vw, 6rem);
    text-align: left;
    padding: 3vmin 5vmin;
  }

  /* a grid item's default min-width:auto refuses to shrink below its content — the wide stages
     would blow the column out instead of fitting it */
  .beat:has(.stage:not(.stage--row)) .stage { min-width: 0; }

  /* the day chart was a fixed 1100px — wider than the column; let it take the column instead */
  .stage--chart { width: 100%; }

  /* NOT ch: `ch` here resolves against .copy's own 16px font, but its children run up to
     1.75rem — 34ch would clamp the column to ~272px and shred the copy into 7 lines. */
  .beat:has(.stage:not(.stage--row)) .copy,
  .beat:has(.stage:not(.stage--row)) .phase {
    align-items: flex-start;
  }
  .beat:has(.stage:not(.stage--row)) .copy { max-width: min(34rem, 34vw); }

  .beat:has(.stage:not(.stage--row)) .lede { max-width: 14ch; }

  /* GROUPED RUNS — see render.js `data-group`. Only the members of a run are pinned to each other;
     the rest of the deck still centres normally.
     Pin by TOPPING OUT the column, not by reserving min-heights on the lines. Reserving worked
     (all four headlines landed together) but left shields — whose headline is one line where its
     siblings run two — with a 150px hole between its headline and its sub, which reads as a bug.
     Topping out holds the headline AND lets each sub sit right under its own.
     29vh keeps the run at the height it already sat at (~320 of 860), so it agrees with the
     ungrouped beats either side of it (crest 320, eagle 298). */
  .beat[data-group] .copy {
    align-self: stretch;
    justify-content: flex-start;
    padding-top: 29vh;
  }

  /* What lands on the run's line is the HEADLINE, not the top of the block — so a run whose beats
     carry no eyebrow has to reserve that line, or it rides 44px above a run that does. The reserve
     is a blank line set in the eyebrow's own font, so it matches the real thing exactly (25px vs
     25px) instead of being a hand-tuned number that drifts as the clamp resolves. */
  .beat[data-group]:not(:has(.whisper)) .copy::before {
    content: "\00a0";
    font-size: clamp(.95rem, 1.6vw, 1.35rem);
  }

  /* Milestone and pipeline captions are SENTENCES, not headlines, so they cannot take the full
     .lede size (73px) at a 14ch measure — that ran an 80-character caption to eight lines and
     pushed the beat off both ends of the screen. But 1.6rem sat 3x below every other heading in
     the deck and read as a different typeface. This is the middle: unmistakably a heading, on a
     measure wide enough to stay 3-4 lines. Inside this query to avoid a specificity fight. */
  .beat:has(.stage:not(.stage--row)) .lede--mile {
    max-width: 22ch;
    font-size: clamp(1.4rem, 2.9vw, 2.5rem);
  }

  /* Three hand-tuned `min-height` reserves used to live here — 3.3em on the pipeline headline,
     4.8em on the milestone caption, 4.35em on the log receipt — each guessing the tallest string
     in em and each only ever applying in landscape. `.swap` replaces all of them: every variant
     is stacked in one grid cell, so the box measures the real tallest member, at every viewport,
     with no numbers to keep true. (PM 2026-07-31) */

  /* freed from sharing vertical space with the text, the art can be much bigger */
  .card             { --h: min(78vh, 640px); }
  /* was min(64vh, 520px) — two cards plus their gap came to 822px, wider than the 700px art
     column, so the pair beats blew the grid out and dragged the text sideways with them */
  .stage--pair .card { --h: min(52vh, 430px); }
  /* ...but a STACKED pair shares one cell and only ever shows one card, so it should be sized like
     the single card it looks like. It picks up .stage--pair from the slug count alone; without this
     the settebello reveal and the tartan back rendered a third smaller than every other lone card. */
  .stage--stack .card { --h: min(78vh, 640px); }
  .card--zoom       { --h: min(82vh, 700px); }
  .card--img        { height: min(74vh, 610px); }
  .plate            { height: min(70vh, 560px); }
  .plate--small     { height: min(38vh, 320px); }
}

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

/* ---- portrait / phone --------------------------------------------------------------------- */

/* The exact complement of the landscape rule above, so every viewport gets one or the other. The
   stacked column is already the BASE layout, so nothing needs re-arranging here — what breaks on
   a phone is vertical FIT. The art was sized for a wide screen's spare height (a card at 56vh),
   and because `html, body` are `overflow: hidden`, anything that doesn't fit is silently cut off
   rather than scrollable. So: give the art less of the column, and the words keep their room. */
@media (max-aspect-ratio: 5/4) {
  .beat { gap: clamp(.9rem, 2.4svh, 2rem); padding: 4vmin 6vmin 8vmin; }

  /* GROUPED RUNS, portrait. Landscape pins a run's members to each other by topping out the copy
     column (see the min-aspect-ratio block); portrait had no equivalent, so each beat centred on
     its own content and members of the same run landed in different places. #us reserves the
     height of its SECOND phase — .phases is a grid, so the box is always as tall as the tallest
     phase, and that one runs ~5 lines here — while #friends carries a single short line, so their
     cards sat ~45px apart despite being the same layout in the same run. Top out the beat itself:
     every member starts its art at one y, and the slack falls at the bottom where it reads as
     margin rather than as misalignment. */
  .beat[data-group] { justify-content: flex-start; padding-top: 7svh; }

  .card              { --h: clamp(150px, 34svh, 380px); }
  .stage--pair .card { --h: clamp(130px, 30svh, 340px); }
  /* A STACKED pair shares one cell and only ever shows one card, so it must be sized like the
     single card it looks like — it picks up .stage--pair from the slug count alone. Landscape
     already carries this rule; without it here, #settebello's card came out 148px against
     #the-seven's 168px, and since the copy sits under the art, the two beats' text landed at
     different heights. Same for #reversal-back. */
  .stage--stack .card { --h: clamp(150px, 34svh, 380px); }
  .card--zoom        { --h: clamp(200px, 52svh, 460px); }
  .card--img         { height: clamp(150px, 34svh, 380px); }
  .plate             { height: clamp(150px, 34svh, 380px); }
  .plate--small      { height: clamp(100px, 22svh, 240px); }

  .emblem { width: clamp(90px, 26vw, 200px); height: clamp(110px, 20svh, 230px); }

  /* THE HERO EMBLEM, on eight beats — the four suits in Act One and the four arc closers. It had
     no portrait rule, so the generic .emblem above overrode it (same specificity, later in the
     file) and a hero rendered at exactly the size of a row emblem: 110px, where a single card is
     168px. Both are "one thing, alone, at the centre of the beat", so they should agree — and a
     hero should never be the same size as one of four in a row. */
  .emblem--hero { width: clamp(120px, 44vw, 280px); height: clamp(150px, 34svh, 300px); }
  /* #ours-doesnt, same story as #suit-map. The chip cell is 199px — emblem pinned to its 84px
     floor again, plus two label lines, the colour bar and a caption — so two rows plus the copy
     came to 607px against Edge's 493px web view. One row of four: ~325px. */
  .stage--row.stage--chips {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: clamp(.3rem, 2vw, .9rem);
    align-items: start;
  }
  .chip { gap: .45rem; }
  .stage--chips .emblem { width: 100%; height: clamp(52px, 12svh, 110px); }
  /* the labels set the column width — "diamonds" at .26em tracking is 90px, wider than the 69px
     a quarter of a phone leaves */
  .chip__was,
  .chip figcaption { font-size: clamp(.6rem, 2.6vw, .8rem); letter-spacing: .06em; }
  .chip__bar { width: clamp(30px, 10vw, 80px); height: clamp(8px, 1.4svh, 14px); }

  /* the chips climb as their labels arrive, exactly as the map's columns do — and it costs the
     same thing on a short screen, where there is no spare room beneath to climb into */
  .chip,
  .beat[data-at="1"] .chip,
  .beat[data-at="2"] .chip { transform: none; }

  /* TYPE. Every size in this stylesheet is a clamp whose fluid term is `vw`-based — and at phone
     width that term falls to 4-6px, so all of them collapse onto their MINIMUM, which was authored
     as a desktop floor rather than as a phone size. Measured at 390x844, six labels landed at
     10-11px. These are the ones that needed a floor of their own. */
  .lede { font-size: clamp(1.7rem, 6.6vw, 3rem); }
  /* was clamp(1rem, 3.6vw, 1.4rem) — a 16px floor, BELOW the base rule's own minimum, so the
     phone ended up with the smallest type in the deck while the desktop ran 27-28px. */
  .sub  { font-size: clamp(1.25rem, 4.8vw, 1.5rem); }

  /* The one size in the deck that was genuinely fluid — and on the wrong axis. `12vh` made the
     numeral 101px on a phone, while the cards beside it shrank to ~74px wide, so the 7 towered
     over the art it belongs to. Drive it off the axis that is actually short. */
  .score__num { font-size: clamp(2.4rem, 15vw, 5rem); }

  /* 10-11px is too small to ask anyone to read on a phone. The prompt is the worst of them: a
     whole paragraph at 10px. It is zoomable, but it should not need rescuing to be legible. */
  .prompt { font-size: .8rem; }

  /* #the-prompt, re-thought. At 13px the 1,327-character prompt runs ~29 lines — about 580px of
     text against ~730px of usable phone height, before the card and the copy. `flex: none` meant
     it could not even compress; it was simply clipped. And what got clipped was the LAST 412
     characters — the red --no list, which is the whole point of the second phase.
     So: a WINDOW rather than a dump. Phase one frames the top of the wall and fades it out at the
     bottom edge, which says "a paragraph of very particular instructions" better than a full page
     of unreadable type does. Tapping still opens the lightbox to read every word. */
  .prompt {
    height: 34svh;
    overflow: hidden;
    scroll-behavior: smooth;          /* story.js sets scrollTop; this is what animates it */
    -webkit-mask-image: linear-gradient(to bottom, #000 78%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 78%, transparent 100%);
  }

  /* Phase two scrolls the window down to the red list rather than hiding the prose above it —
     the point of that phase is that the rejections are PART OF the same paragraph. The fade moves
     to the TOP only: a hard top edge would read as the beginning of the prompt, which is the wrong
     thing to say there, while the bottom now IS the end of the prompt and should look like it. */
  .beat[data-at="1"] .prompt {
    -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 10%);
    mask-image: linear-gradient(to bottom, transparent 0%, #000 10%);
  }

  /* A block, so the flags start on a line boundary and the scroll lands somewhere clean. */
  .prompt__prose { display: block; }
  .swatch__tones { font-size: .75rem; }   /* .swatch__name is set with the palette rules below */
  .daynote__when, .stat span, .pipe { font-size: .75rem; }
  .stamp--log { font-size: .72rem; }

  /* SIX stages are explicitly forbidden from reflowing (`flex-wrap: nowrap`, or a width cap), so
     shrinking them achieves nothing — each needs a different ARRANGEMENT in a tall window, not a
     smaller one. This is the part scaling can't do. */

  /* THREE OR MORE cards/plates wrap to two rows here, so each one can only have about half the
     height it gets alone. At 34svh two rows came to 608px against ~498px of stage room — and with
     overflow hidden, the bottom row was simply cut off rather than shrunk. The margin got thinner
     again when the sub type grew, which is what surfaced it. Cards size from --h, plates and
     graveyard frames from height, so both have to be said. */
  /* 24svh was still too tall once the copy was measured honestly: #midjourney's second phase runs
     five lines, so two rows of cards plus that came to 533px against 493. 18svh clears it, and
     #knights — whose copy is half as long — gains room it did not have either. */
  .stage--row:has(> :nth-child(3)) .card,
  .stage--many .card { --h: clamp(84px, 18svh, 190px); }
  .stage--row:has(> :nth-child(3)) .plate,
  .stage--row:has(> :nth-child(3)) .card--img { height: clamp(84px, 18svh, 190px); }

  /* Four of anything → 2x2. Letting flex wrap on its own gives 3+1, which strands the fourth on
     a line of its own and stops the four reading as one set. Catches the knights, the four
     emblems, the chips, the suit map, the score tiles and every palette row at once. */
  .stage--row:has(> :nth-child(4)),
  .stage--many,
  .stage--scores,
  .stage--palette {
    display: grid;
    grid-template-columns: repeat(2, auto);
    justify-items: center;
    align-items: start;
    gap: clamp(1rem, 4svh, 2.4rem) clamp(1rem, 5vw, 3rem);
  }

  /* Two stages put a card BESIDE something and forbid wrapping, so in a narrow window both halves
     get crushed instead of one moving. Stack them, and let the card give up the height: on
     #the-prompt the paragraph is the subject, and on #wardrobe the colours are. */
  .stage--prompt,
  .stage--wardrobe { flex-direction: column; flex-wrap: nowrap; }
  /* On #the-prompt the paragraph is the exhibit and the card is context, so the card gives up the
     height: at 24svh the beat came to 488 of 493, which is no margin at all. */
  .stage--prompt .card { --h: clamp(80px, 16svh, 200px); }
  .stage--wardrobe .card { --h: clamp(120px, 24svh, 260px); }

  /* The score tiles need more than the 2x2 above. Their art box is sized off HEIGHT (`20vh`),
     but the fan is three cards overlapped at -34%, making it ~1.7x that height in WIDTH — so in
     a half-width column the fan alone still overflowed while the other three fitted. Size the
     art off the viewport's WIDTH here, which is the axis that's actually short, and give the
     four equal columns so the two rows line up instead of stepping. */
  /* Two rows, brought up. Kept at 2x2 rather than four across because this beat is the one four-up
     that cannot take it: the fan is three overlapped cards, the widest art in the deck relative to
     its height, and these labels are PHRASES rather than words — at a quarter of a phone the art
     box would fall to ~47px and "one single card" would run three lines.
     What actually overflowed: the captions wrapped to two lines each, adding ~38px, while the copy
     is taller than it looks (the lede wraps to two lines and the sub to three, ~164px). That put
     the beat over 493px, and .stage shrinking below its content drew the second row's captions
     over the sub. Smaller art, tighter gaps, and a caption size that fits "one single card" on ONE
     line — which is what makes the two rows agree as well as fit. */
  .stage--scores {
    grid-template-columns: repeat(2, 1fr);
    gap: clamp(.6rem, 2svh, 1.2rem) clamp(1rem, 5vw, 2rem);
  }
  .stage--scores .score { gap: clamp(.5rem, 1.6svh, 1rem); }
  .stage--scores .score__art { --art-h: clamp(56px, 20vw, 120px); }
  .stage--scores .score__name { font-size: clamp(.62rem, 2.6vw, .8rem); letter-spacing: .08em; }

  /* A grid item's default `min-width: auto` refuses to shrink below its content — the same trap
     the landscape rule above documents. Without this the fan does not get squeezed into its
     column, it overflows it, and `body { overflow: hidden }` cuts the result off. */
  .stage--row > *, .stage--many > *, .stage--scores > *, .stage--palette > * { min-width: 0; }
  .score__art { max-width: 100%; }

  /* Even sized correctly the fan is the widest thing in the deck relative to its height: three
     cards at -34% each = 2.32 card-widths = 1.7x the art box's height. At a 162px column that is
     172px — still over. Overlapping them further costs nothing (the fan reads as "a lot of
     cards" either way) and buys the whole tile back: -50% brings it to 149px. */
  .score--fan .score__art > * { margin-left: -50%; }

  /* #suit-map. Each of the four columns is a suit, and the CHAIN runs downward inside it — semi,
     Italian name, German word, French pip, English name. Folded to 2x2 that is two rows of 309px,
     and the last phase is the worst of the four because .eng only arrives then: 854px of beat
     against ~730px of usable phone height. The two big items pay for it. */
  /* #suit-map is the one four-up that must NOT fold to 2x2, and it needs its own floors.
     MEASURED on Edge/iOS: its chrome leaves a 493px web view on a 693px phone screen — 200px less
     than the whole screen, and ~70px less than Chrome's. At that height 13svh computes to 64px, so
     the semi's own 96px FLOOR won and svh never came into play: on a short viewport the clamp
     minimums are what set the height, not the viewport unit. Two rows of cells then cost 397px,
     which with the copy is 585 against 493 — and because .stage may shrink below its content, the
     overflow draws OVER the words instead of being clipped, which is what the screenshot showed.
     One row of four halves the stage and every floor comes down with it: ~350px, comfortably in. */
  .stage--row.stage--map {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: clamp(.3rem, 2vw, .9rem);
    align-items: start;
  }
  .map .semi { height: clamp(56px, 15svh, 120px); width: 100%; }
  /* the labels set the COLUMN width here — "diamonds" at the deck's usual .26em tracking is wider
     than a quarter of a phone, so the tracking comes in with the size */
  .map figcaption,
  .map .ger,
  .map .eng { font-size: clamp(.65rem, 2.8vw, .85rem); letter-spacing: .06em; }
  .map .pip { font-size: clamp(1.2rem, 6vw, 1.8rem); }

  /* The chain-climb (translateY 9.6vh → 0, so the group stays optically centred as links appear)
     assumes ONE row with space beneath it. In two rows it only pushes the bottom row off screen. */
  .map,
  .beat[data-at="1"] .map,
  .beat[data-at="2"] .map,
  .beat[data-at="3"] .map { transform: none; }

  /* THE STAGES THIS BLOCK NEVER SIZED. Every beat reported as overlapping — #tone, #palette,
     #wardrobe's swatch, #claude, #forge-pipeline — turned out to have no portrait rule at all, so
     it was rendering at DESKTOP dimensions on a phone. Their base sizes are `vh`-fluid but with
     desktop floors, and at 493px the floors win every time (a tone strip is six 22px bands = 132px
     before its name). Sizing them here is the fix; the safety net below is what stops the next one
     from being unreadable rather than merely cramped. */

  /* #tone and #palette stay in ONE row of four, for the same reason the map and the chips do:
     folded to 2x2 the swatches came to 536px against 493. Four narrow columns is also how the
     beat reads — four alternatives side by side, or four suits side by side. */
  .stage--row.stage--palette {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: clamp(.3rem, 2vw, .9rem);
    align-items: start;
  }

  /* #tone — four candidate palettes, six bands each. The strip took 26vw = 83px in a 68.8px
     column, so the four ran into each other; it takes the column instead. */
  .tone-strip { width: 100%; }
  .tone-strip__band { height: clamp(8px, 1.6svh, 15px); }

  /* THE NAMES UNDER THEM. .swatch__name is `white-space: nowrap`, which is right on a wide screen
     and impossible here: "cathedral jewel" at 12px/.18em is ~144px of unbreakable text in a 68.8px
     column, so it simply ran across its neighbours. Let it wrap, and bring the size and tracking
     down far enough that the longest WORD — "cathedral", 57px — fits inside one column. */
  .swatch__name {
    white-space: normal;
    font-size: clamp(.55rem, 2.4vw, .72rem);
    letter-spacing: .04em;
    line-height: 1.35;
  }

  /* #palette — the four suit swatches, and their emblem badge */
  .swatch { gap: .4rem; }
  .swatch__chip { width: 100%; height: clamp(40px, 9svh, 78px); }
  .emblem.swatch__emblem { width: clamp(38px, 13vw, 64px); height: clamp(40px, 8svh, 66px); }

  /* #wardrobe — the pair sat beside the court card at full desktop width, which is what pushed
     its "CUPS" caption into the art */
  .stage--wardrobe .swatch { max-width: 42vw; }
  .swatch__pair .swatch__chip { width: clamp(56px, 22vw, 110px); }

  /* #claude and #forge-scale — the counter boards */
  .stat b { font-size: clamp(1.4rem, 7vw, 2.2rem); }
  .stage--stats { gap: clamp(.7rem, 2svh, 1.6rem) clamp(1.2rem, 6vw, 3rem); }
  .stage--craft { gap: clamp(.6rem, 1.8svh, 1.4rem); }

  /* #forge-pipeline. The stage is `width: min(46vw, 520px)` — written for the landscape layout,
     where it is one of two columns. In portrait 46vw is 147px on a 320px screen, so the specimen
     and the step rail were both being crushed into less than half the width available and then
     clipped by the stage's overflow. It takes the full column here.
     The specimen also needs a real HEIGHT, not a max-height: its grid row is minmax(0, 1fr), and
     the layers size themselves with max-height: 100% against it — a definite height is what lets
     them scale to fit instead of being cut. */
  .stage--pipe { width: 100%; gap: clamp(.6rem, 2svh, 1.2rem); }
  .specimen { height: clamp(140px, 30svh, 300px); max-height: none; }

  /* THE SAFETY NET. .stage is a flex item with min-height: 0, so it shrinks below its own content
     and the art then paints OVER the copy laid out after it — that is what every one of these
     reports looked like, and it reads as broken rather than as tight. Clipping instead means the
     worst case is a cropped picture, which reads as a picture. */
  .stage { overflow: hidden; }

  /* Capped at min(92vw, 1100px), with the fix living only inside the landscape rule. */
  .stage--chart { width: 100%; }

  /* auto-fit at minmax(210px, 1fr) collapses to ONE column on a phone — 56 entries in a fixed
     box with no overflow rule, so most of the list simply could not be reached. */
  .index {
    align-content: start;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
  }
}

/* ---- the coach mark (touch, opening beat, once) -------------------------------------------- */

/* Swipe and tap-zones are invisible affordances — nothing on a phone says which half goes back.
   This shows the two halves once, names them, and leaves at the first touch. Built by story.js
   only when the pointer is coarse, so it never exists on a machine that has arrow keys. */
.coach {
  position: fixed;
  inset: 0;
  z-index: 4;                 /* over the beats, under the lightbox (5) */
  display: flex;
  pointer-events: none;       /* the tap it is describing must reach the page */
  opacity: 0;
  transition: opacity 620ms ease;
}

.coach.is-shown { opacity: 1; }

.coach__half {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: .5rem;
  color: var(--parchment);
}

/* the same 25% the click handler uses for "back" — the picture must not lie about the target */
.coach__half--back { flex: 0 0 25%; background: linear-gradient(to right, rgba(8,5,2,.62), transparent); }
.coach__half--next { flex: 1 1 auto; background: linear-gradient(to left,  rgba(8,5,2,.62), transparent); }

.coach__arrow { font-size: clamp(2.4rem, 12vw, 4rem); line-height: 1; opacity: .9; }

.coach__word {
  font-size: clamp(.8rem, 3.4vw, 1rem);
  text-transform: uppercase;
  letter-spacing: .26em;
  color: var(--parch-soft);
}
