/* ==========================================================================
   William Dooling — Portfolio
   Tokens follow _internal/portfolio-design-guide.md §4.

   A deliberate addition beyond the guide is a broadcast/scoreboard framing
   layer — ghost numerals, hairline rules with
      tick marks, tracked micro-labels. §2 asks for sports-coded personality;
      previously only the typeface carried it.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */

:root {
  /* Guide §4 palette */
  --bg:          #0A0A0A;
  --text:        #F5F5F0;
  --text-dim:    #8A8A85;
  --bev:         #F2711C;
  --storm:       #7FDBB6;
  --storm-gold:  #E8C368;
  --ygr:         #7AC138; /* sampled from Yggdrasil's own node-highlight green */

  /* Violet stays in small interface accents and the profile control. */
  --violet-deep: #140B24;
  --violet-mid:  #2E1E52;
  --violet-core: #6B54A8;
  --violet-glow: #9B84D9;
  --about-blue:  #58BFF3; /* existing Bifröst blue from the portfolio palette */

  /* Surfaces */
  --surface:     #121114;
  --surface-lo:  #0D0D0F;
  --line:        rgba(245, 245, 240, .10);
  --line-soft:   rgba(245, 245, 240, .055);
  --line-tick:   rgba(245, 245, 240, .28);

  /* Type */
  --display: 'Quicksand', 'Quicksand Fallback', 'Trebuchet MS', sans-serif;
  --body:    'Inter', 'Inter Fallback', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;

  /* Per-project accent. Overridden by .card--* and .theme-*. */
  --accent: var(--violet-glow);

  /* Rhythm */
  --gutter:  clamp(1.25rem, 4.5vw, 4rem);
  --measure: 34rem;   /* ~70 characters at 16px Inter */

  /* Motion */
  --float-range: 14px;
  --ease:        cubic-bezier(.4, 0, .2, 1);
  --ease-out:    cubic-bezier(.16, 1, .3, 1);    /* fast start, long settle */
  --ease-in:     cubic-bezier(.5, 0, .78, .4);   /* accelerates away        */

}

/* ---------------------------------------------------------------------------
   Web fonts, self-hosted.

   These used to come from fonts.googleapis.com, which meant a render-blocking
   round trip to one third party followed by the font itself from a second
   (fonts.gstatic.com) — both sitting on the critical path of every first paint.
   Hosting these first-party avoids extra DNS and TLS work between navigation
   and a correctly rendered page.

   Both faces are VARIABLE. Google's css2 endpoint declares Inter twice, at 400
   and 600, but both declarations point at the same file — it carries a `wght`
   axis and the browser instances it. So one @font-face per subset spans the
   whole range rather than one per weight, and asking for 600 costs no second
   download.

   Only `latin` and `latin-ext` are shipped; cyrillic, greek and vietnamese were
   dropped, as nothing on this site is written in them. `unicode-range` makes
   latin-ext free until something actually needs it — the file sits in the repo
   and is never fetched for English text, so it is insurance rather than weight.

   Both are OFL-1.1 and may be self-hosted. See assets/fonts/README.md.
   --------------------------------------------------------------------------- */

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

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url('fonts/inter-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

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

@font-face {
  font-family: 'Quicksand';
  font-style: normal;
  font-weight: 300 700;
  font-display: swap;
  src: url('fonts/quicksand-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* Metric-matched fallbacks so the font swap doesn't shift layout. Still earning
   their place with the fonts self-hosted: `font-display: swap` above means the
   first frame can be drawn in a fallback either way.
   Quicksand is a rounded geometric sans with a large x-height; Trebuchet is
   the closest thing installed on both Windows and macOS. */
@font-face {
  font-family: 'Quicksand Fallback';
  src: local('Trebuchet MS'), local('Verdana'), local('Segoe UI'), local('Arial');
  size-adjust: 97%;
  ascent-override: 95%;
  descent-override: 24%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Inter Fallback';
  src: local('Segoe UI'), local('Helvetica Neue'), local('Arial');
  size-adjust: 100%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* --------------------------------------------------------------------------
   2. Base
   -------------------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  background: var(--bg);
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--body);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

a { color: inherit; }

img { max-width: 100%; display: block; }

:focus-visible {
  outline: 2px solid var(--violet-glow);
  outline-offset: 4px;
  border-radius: 3px;
}

/* Visually hidden, still read aloud and still indexed. Used for the h1, which
   carries the name the design no longer shows. Not `display: none`, which
   would remove it from the accessibility tree entirely. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

::selection { background: color-mix(in srgb, var(--violet-core) 60%, transparent); }

/* Shared micro-label: tracked, uppercase, scoreboard-adjacent. */
.micro {
  font-size: .68rem;
  font-weight: 600;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* Hairline rule with broadcast tick marks. */
.rule {
  position: relative;
  height: 1px;
  background: var(--line);
  border: 0;
  margin: 0;
}

.rule::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 6px;
  background-image: repeating-linear-gradient(
    90deg, var(--line-tick) 0 1px, transparent 1px 12.5%);
  opacity: .55;
}

/* --------------------------------------------------------------------------
   3. Homepage shell
   -------------------------------------------------------------------------- */

.home {
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  overflow-x: clip;
  position: relative;
}

/* Keep the homepage interface above its project art. */
.marquee, .roster, .site-foot { position: relative; z-index: 1; }

/* --------------------------------------------------------------------------
   4. Marquee (header)
   -------------------------------------------------------------------------- */

/* The header is now a single quiet label. Everything it used to carry — the
   display-size name, the role, the tagline, the intro slot — was cut so the
   two project cards can hold the page on their own. */
.marquee {
  padding: clamp(1.25rem, 3vw, 2rem) var(--gutter) 0;
}

/* Sentence-case now rather than the tracked micro-label look: the copy is a
   full sentence with an italic emphasis, which reads badly all-caps. */
.marquee__label {
  margin: 0;
  color: var(--text-dim);
  font-family: var(--body);
  font-size: clamp(.9rem, 1.6vw, 1.05rem);
  font-weight: 400;
  letter-spacing: normal;
  text-transform: none;
}

.marquee__label em { font-style: italic; color: var(--text); }

/* --------------------------------------------------------------------------
   5. Roster (the two project cards)
   -------------------------------------------------------------------------- */

.roster {
  flex: 1;
  display: flex;
  flex-wrap: wrap; /* three cards no longer guarantee one row; wrap rather than squish */
  align-items: center;
  justify-content: center;
  gap: clamp(1.75rem, 5vw, 4rem);
  padding: clamp(1.5rem, 3.5vw, 2.75rem) var(--gutter) clamp(1.5rem, 3vw, 2.5rem);
  perspective: 1400px;
}

.card {
  display: block;
  position: relative;
  text-decoration: none;
  /* Sized for three cards abreast rather than two — narrower ceiling and a
     smaller vw share than the old two-card formula so a normal desktop width
     can fit all three before .roster's wrap kicks in. */
  width: clamp(280px, 28vw, 440px);
  flex: 0 1 auto;
  -webkit-tap-highlight-color: transparent;
}

/* Stagger — asymmetry by position, not by size, so no project reads as ranked
   above the others. */
.card--storm { margin-top: clamp(1.5rem, 4vw, 3.5rem); }
.card--ygr   { margin-top: clamp(.5rem, 1.5vw, 1.5rem); }

/* The ghost roster numerals ("01"/"02" outlined behind each card) were cut —
   with the page down to two cards, numbering them added ranking that isn't
   there. The case study headers keep their own numeral. */

/* Each transform gets its own element so idle float, pointer tilt, and hover
   lift never overwrite one another. */
.card__float {
  animation: float 7s ease-in-out infinite;
  will-change: transform;
}

.card--storm .card__float {
  animation-duration: 7.8s;
  animation-delay: -2.6s; /* offset — the cards must never drift in sync */
}

.card--ygr .card__float {
  animation-duration: 8.4s;
  animation-delay: -4.9s;
}

@keyframes float {
  0%, 100% { transform: translateY(calc(var(--float-range) / -2)); }
  50%      { transform: translateY(calc(var(--float-range) /  2)); }
}

.card__tilt {
  transform: rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
  transform-style: preserve-3d;
  transition: transform 420ms var(--ease-out);
}

.card:hover .card__tilt { transition-duration: 90ms; } /* track the pointer live */

.card__inner {
  position: relative;
  z-index: 1;
  border-radius: 18px;
  padding: clamp(1rem, 2vw, 1.35rem);
  overflow: hidden;
  background:
    linear-gradient(180deg,
      color-mix(in srgb, var(--accent) 7%, var(--surface)) 0%,
      var(--surface-lo) 62%);
  border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent);
  box-shadow:
    inset 0 1px 0 rgba(245, 245, 240, .08),
    0 1px 0 0 rgba(0, 0, 0, .6),
    0 22px 58px -14px color-mix(in srgb, var(--accent) 32%, transparent);
  transition: transform 360ms var(--ease-out), box-shadow 360ms var(--ease),
              border-color 360ms var(--ease);
}

/* Outer bloom, on its own element so it can swell independently of the card. */
.card__inner::after {
  content: '';
  position: absolute;
  inset: -22%;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(closest-side,
              color-mix(in srgb, var(--accent) 40%, transparent), transparent 78%);
  filter: blur(38px);
  opacity: .5;
  transition: opacity 360ms var(--ease);
  pointer-events: none;
}

/* Pointer-tracked specular sweep. --mx/--my are set by main.js. */
.card__sheen {
  position: absolute;
  inset: 0;
  z-index: 2;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 320ms var(--ease);
  background: radial-gradient(38% 50% at var(--mx, 50%) var(--my, 50%),
              color-mix(in srgb, var(--accent) 26%, transparent) 0%,
              transparent 68%);
  mix-blend-mode: screen;
}

.card:hover .card__sheen,
.card:focus-visible .card__sheen { opacity: 1; }

.card:hover .card__float,
.card:focus-visible .card__float { animation-play-state: paused; }

/* Hover pose. The pop animation below must land on exactly this transform:
   the animation runs with fill none, so when it finishes the value falls back
   to this rule. If the two disagree, the card snaps at the handoff. */
.card:hover .card__inner,
.card:focus-visible .card__inner {
  transform: translateY(-14px) scale(1.05);
  border-color: color-mix(in srgb, var(--accent) 52%, transparent);
  box-shadow:
    inset 0 1px 0 rgba(245, 245, 240, .12),
    0 1px 0 0 rgba(0, 0, 0, .6),
    0 34px 78px -12px color-mix(in srgb, var(--accent) 50%, transparent);
}

/* The pop is declared OUTSIDE the hover rule, and that separation is the whole
   of A9's fix (the same fix is applied to the deck below).

   A CSS animation named inside a conditional rule is *added* the moment the
   rule starts matching and *removed* the moment it stops. So every momentary
   loss of the trigger restarts the bounce from 0% — and under a moving pointer
   the trigger does flicker, because the card's live tilt and the pop's own
   16px lift both slide the geometry out from under a pointer that has barely
   moved. The animation cancels its own trigger; that feedback loop is why it
   read as "rapidly repeating" rather than as a single re-fire.

   :focus-visible cannot flicker under a moving pointer, so it keeps declaring
   the animation directly and still pops with JS off. Hover fires `is-popping`
   from main.js instead: added once per genuine entry, dropped on animationend,
   and held through a short grace period so a flicker cannot re-fire it.

   These stay above :active so the press state can cancel the animation. */
.card:focus-visible .card__inner { animation: card-pop 460ms var(--ease-out); }
.card.is-popping    .card__inner { animation: card-pop 460ms var(--ease-out); }

/* Squash and stretch. The card leaps wide-and-flat, overcorrects to
   narrow-and-tall, then rings down onto the hover pose. scaleX and scaleY are
   kept as separate functions rather than a single scale() so the two axes can
   disagree — that disagreement IS the effect.

   Peak is ~1.072 against a 1.05 rest, so the overshoot is visible without the
   card ever looking like it changed size permanently. */
@keyframes card-pop {
  0%   { transform: translateY(0)      scaleX(1)     scaleY(1);     }
  18%  { transform: translateY(-16px)  scaleX(1.072) scaleY(1.028); }
  38%  { transform: translateY(-11px)  scaleX(1.032) scaleY(1.066); }
  58%  { transform: translateY(-15px)  scaleX(1.061) scaleY(1.042); }
  78%  { transform: translateY(-13.5px) scaleX(1.046) scaleY(1.053); }
  100% { transform: translateY(-14px)  scaleX(1.05)  scaleY(1.05);  }
}

.card:hover .card__inner::after,
.card:focus-visible .card__inner::after { opacity: .95; }

/* Press. `animation: none` matters — without it the still-running pop keeps
   control of transform and the card refuses to depress under the click. */
.card:active .card__inner {
  transform: translateY(-2px) scale(.995);
  animation: none;
  transition-duration: 110ms;
}

.card--bev   { --accent: var(--bev); }
.card--storm { --accent: var(--storm); }
.card--ygr   { --accent: var(--ygr); }

/* Card art ---------------------------------------------------------------- */

.card__art {
  position: relative;
  aspect-ratio: 16 / 10;   /* was 4/3, which cropped ~27% off the capsule art */
  border-radius: 11px;
  overflow: hidden;
  background: #0A0A0C;
  border: 1px solid var(--line-soft);
}

/* Direct child only. The bev card fills its art slot with a fanned deck of
   phone screenshots instead of a single image, and those must not inherit the
   cover-fit-and-creep behaviour that belongs to a full-bleed capsule. */
.card__art > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.02);
  transition: transform 700ms var(--ease-out);
}

.card:hover .card__art > img { transform: scale(1.06); }

/* Bottom vignette so the art never fights the title block.

   z-index matters here. A pseudo-element with `z-index: auto` paints above its
   siblings only while they are also auto; the deck's phones carry z-indices of
   their own, so without this the vignette would sit *under* them and the bleed
   off the bottom edge would end in a hard cut instead of a fade. Harmless for
   the storm card, whose single <img> is auto either way. */
.card__art::after {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  z-index: 4;
  height: 42%;
  background: linear-gradient(to top, var(--surface-lo), transparent);
  opacity: .55;
  pointer-events: none;
}

/* Card art — bevfantasy's fanned phone deck ------------------------------- */

.deck {
  position: absolute;
  inset: 0;
  transition: transform 420ms var(--ease-out);
}

/* The deck reacts as ONE object. Hovering any single phone used to lift and
   straighten just that phone, which read badly: three separate targets inside a
   card that is a single link, each fighting the card's own 1.05 pop underneath.
   Now touching any phone bounces the whole fan.

   `:has(.deck__shot:hover)` rather than `.deck:hover` on purpose — .deck spans
   the full art box, so plain :hover would also fire in the empty gaps between
   phones and the deck would react to a pointer that is not on anything.

   Scale is 1.035, not the card's 1.05. The outer phones sit 8px from the frame
   edge and .card__art clips, so a larger scale pulls their outer edges under
   the clip and the fan looks cropped rather than lifted.

   A9: the pose and the bounce are now two separate rules, for the reason
   written out over `.card.is-popping` above. `:has(.deck__shot:hover)` still
   carries the POSE, so the deck still lifts with JS off and the touch override
   still has something to cancel. `.is-lifted` is main.js's latch and `.is-popping`
   its one-shot bounce.

   The latch IS a widened trigger, deliberately and explicitly: entering still
   requires the pointer to be on an actual phone, but once it is, the deck stays
   lifted until the pointer leaves .card__art entirely. It does not drop in the
   gaps between phones any more. That is the hysteresis — without it the phones
   move out from under the pointer (the fan translates -15px mid-bounce, and the
   card's own pop rings through four reversals under it) and the pose chatters.
   The original reason for `:has()` over `.deck:hover` is untouched: you still
   cannot start the lift by pointing at empty deck. */
.deck:has(.deck__shot:hover),
.deck.is-lifted {
  transform: translateY(-12px) scale(1.035);
}

.deck.is-popping {
  animation: deck-pop 460ms var(--ease-out);
}

/* B3's squash-and-stretch, borrowed wholesale including its trap: the animation
   runs with `fill: none`, so when it ends the value falls back to the rule
   above. The 100% keyframe must therefore resolve to exactly that matrix or the
   deck snaps at the handoff. scaleX/scaleY are separate functions here while
   the rule uses scale() — different lists, identical matrix, which is fine
   because this is a value swap at the end and not an interpolation. */
@keyframes deck-pop {
  0%   { transform: translateY(0)     scaleX(1)     scaleY(1);     }
  28%  { transform: translateY(-15px) scaleX(1.052) scaleY(1.012); }
  52%  { transform: translateY(-8px)  scaleX(1.014) scaleY(1.052); }
  76%  { transform: translateY(-14px) scaleX(1.044) scaleY(1.028); }
  100% { transform: translateY(-12px) scaleX(1.035) scaleY(1.035); }
}

/* The fan angles are the only per-shot transform left. An earlier pass had each
   shot composing its transform from --tilt/--rise/--pop so that hover could
   restate them individually; the group bounce replaced all of that, so those
   properties are gone rather than left sitting unused. --x survives because the
   hero centres itself with a percentage translate. */
.deck__shot {
  --x: 0px;
  --tilt: 0deg;

  position: absolute;
  overflow: hidden;
  border-radius: 15px;
  background: #0A0A0C;
  /* The phones carry the card's accent, not the profile palette — §4's
     split-palette rule. On this card that resolves to --bev orange, which is
     also the accent inside the screenshots themselves, so the frame and its
     contents agree. */
  border: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
  box-shadow:
    0 18px 34px -10px rgba(0, 0, 0, .78),
    0 0 0 1px rgba(0, 0, 0, .5),
    0 0 22px -4px color-mix(in srgb, var(--accent) 26%, transparent);
  transform: translateX(var(--x)) rotate(var(--tilt));
  transition:
    border-color 420ms var(--ease),
    box-shadow 420ms var(--ease);
}

.deck__shot img {
  display: block;
  width: 100%;
  height: auto;
}

/* Geometry. Widths are percentages of the art box, so the fan holds its
   proportions at every breakpoint without a single media query.

   The shots are 0.513 wide-to-tall. At 30.5% width a side phone stands 59.5% of
   the art's *width* tall, which against a 16:10 box is 95% of its height — so a
   top of 15% puts its foot well past the lower edge. That overhang is the
   point; the vignette above fades it out. */
.deck__shot--hero {
  --x: -50%;
  z-index: 2;
  left: 50%;
  top: 7%;
  width: 38%;
}

.deck__shot--left {
  --tilt: -8deg;
  z-index: 1;
  left: 6%;
  top: 15%;
  width: 30.5%;
}

.deck__shot--right {
  --tilt: 8deg;
  z-index: 1;
  right: 6%;
  top: 15%;
  width: 30.5%;
}

/* All three light up together with the bounce. No phone moves relative to its
   neighbours — the fan keeps its shape and only the group transform carries the
   motion, which is what makes it read as one object rather than three. */
.deck:has(.deck__shot:hover) .deck__shot,
.deck.is-lifted .deck__shot {
  border-color: color-mix(in srgb, var(--accent) 88%, transparent);
  box-shadow:
    0 26px 46px -10px rgba(0, 0, 0, .85),
    0 0 0 1px rgba(0, 0, 0, .5),
    0 0 34px -2px color-mix(in srgb, var(--accent) 55%, transparent);
}

/* The shine. A narrow specular bar crosses each screen on a long loop, so the
   deck reads as glass rather than as three flat rectangles.

   It travels from -60% to 160% and then holds off-screen for the rest of the
   cycle: 14% of 9s is 1.26s of movement against ~7.7s of quiet, which is what
   makes it read as an occasional glint instead of a windscreen wiper. The
   holding frames are also why the 100% keyframe is the resting state — under
   reduced motion the duration collapses to ~0 and the animation parks on its
   last frame, which must be "no shine" rather than "shine frozen mid-screen". */
.deck__shot::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(102deg,
    transparent 38%,
    color-mix(in srgb, var(--accent) 10%, transparent) 46%,
    rgba(245, 245, 240, .22) 50%,
    color-mix(in srgb, var(--accent) 10%, transparent) 54%,
    transparent 62%);
  animation: deck-shine 9s var(--ease) infinite;
}

.deck__shot--left::after  { animation-delay: 3s; }
.deck__shot--right::after { animation-delay: 6s; }

@keyframes deck-shine {
  0%   { transform: translateX(-60%); opacity: 0; }
  2%   { opacity: .9; }
  12%  { opacity: .9; }
  14%  { transform: translateX(160%); opacity: 0; }
  100% { transform: translateX(160%); opacity: 0; }
}

/* Hover brightens the glint rather than speeding it up. Retiming a running
   animation does not restart it — it reinterprets its current time against the
   new duration, which can teleport the bar into the middle of the screen. The
   shine is invisible for 86% of its cycle, so that jump would land in view
   roughly one time in seven. Intensity has no such failure mode. */
.card:hover .deck__shot::after { filter: brightness(1.45); }

/* The hero also carries a light travelling its border — the ambient half of the
   treatment, so the card is alive before the pointer arrives.

   Standard two-layer mask trick: the element is painted with a conic gradient
   and then masked by (its own box XOR its content box), leaving only the 2px
   padding ring. `exclude` is the spec name and `xor` the -webkit- one; both are
   listed because Safari shipped the prefixed form first.

   What moves is the gradient's start angle, NOT the element. Rotating the
   element was the obvious first reach and it is wrong: the ring is a rounded
   rectangle, so spinning it swings the whole shape off the phone's border and
   the "light" arrives as a tilted frame. Animating a registered <angle> keeps
   the ring welded to the border and moves only the bright arc along it.

   Without @property the custom property is unregistered, so the var() falls
   back to 0deg and the animation cannot interpolate: the ring degrades to a
   static glint at the top-right rather than disappearing. */
@property --deck-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.deck__shot--hero::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  padding: 2px;
  border-radius: inherit;
  pointer-events: none;
  background: conic-gradient(from var(--deck-angle, 0deg),
    transparent 0deg,
    transparent 250deg,
    color-mix(in srgb, var(--accent) 70%, transparent) 300deg,
    rgba(245, 245, 240, .85) 330deg,
    color-mix(in srgb, var(--accent) 70%, transparent) 355deg,
    transparent 360deg);
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
          mask-composite: exclude;
  animation: deck-orbit 6.5s linear infinite;
  opacity: .72;
  transition: opacity 320ms var(--ease);
}

.card:hover .deck__shot--hero::before { opacity: 1; }

@keyframes deck-orbit {
  to { --deck-angle: 360deg; }
}

.card__foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 1.1rem;
}

.card__title {
  margin: 0;
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(1.9rem, 3.4vw, 2.6rem);
  letter-spacing: .03em;
  line-height: .9;
  color: var(--accent);
}

.card__meta {
  margin: .4rem 0 0;
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.35;
}

.card__status {
  flex: none;
  padding: .3rem .6rem;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  color: color-mix(in srgb, var(--accent) 82%, var(--text));
  font-size: .62rem;
  font-weight: 600;
  letter-spacing: .16em;
  text-transform: uppercase;
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   6. Site footer — the profile dot and its panel
   -------------------------------------------------------------------------- */

.site-foot {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 var(--gutter) clamp(1.5rem, 3.5vw, 2.5rem);
}

/* The identity, condensed to one glowing dot. §3 wanted the photo present
   rather than tucked away; this keeps it present but folds it into a single
   point of light so the two cards hold the page uncontested. */
.me {
  position: relative;
  display: flex;
  justify-content: center;
}

/* Liquid blob button. Two things make the "liquid": the outline morphs, and a
   real fill sloshes inside it. The fill is two oversized counter-rotating
   blobs with soft border-radii, clipped to the button — the same trick as the
   classic liquid-button demo, but anchored to a level that rises on hover
   instead of a rectangle that slides. Deliberately not an SVG goo filter,
   which would re-rasterize every moving frame. */
.me__dot {
  position: relative;
  display: grid;
  place-items: center;
  width: 54px;
  height: 54px;
  padding: 0;
  border: 0;
  cursor: pointer;
  font: inherit;
  color: var(--text);
  /* The unfilled glass the liquid sits in. Kept dark so the fill line reads. */
  background:
    radial-gradient(120% 120% at 50% 0%,
      color-mix(in srgb, var(--me-sink) 62%, transparent) 0%,
      color-mix(in srgb, var(--surface-lo) 92%, transparent) 70%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .22),
    inset 0 0 0 1px color-mix(in srgb, var(--me-hot) 30%, transparent),
    0 0 22px 2px color-mix(in srgb, var(--me-hot) 46%, transparent);
  border-radius: 52% 48% 45% 55% / 50% 45% 55% 50%;
  animation:
    liquid-morph 11s ease-in-out infinite,
    liquid-fade  5.2s ease-in-out infinite;
  transition: transform 320ms var(--ease-out), box-shadow 320ms var(--ease);
}

/* The filter definition itself renders nothing. */
.me__goo-def { position: absolute; width: 0; height: 0; }

/* border-radius: inherit resolves against the parent's *computed* value, which
   during an animation is the animated value — so the clip tracks the morph
   frame for frame without duplicating the keyframes. */
.me__liquid {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  border-radius: inherit;
  pointer-events: none;
}

/* Everything inside is filtered as one group, so the waves and the motes fuse
   into each other where they touch. The filter lives here rather than on
   .me__liquid so the blur can never soften the button's own silhouette — the
   clip above is applied to the already-gooed result. */
.me__goo {
  position: absolute;
  inset: 0;
  filter: url(#me-goo);
}

/* Both waves are wider than the button, so only a slice of their upper edge
   crosses it. Rotating a 42%-radius square wobbles that edge by a few pixels —
   which is the whole illusion. `top` carries the fill level and is the only
   transitioned property, leaving transform free for the spin. */
.me__goo::before,
.me__goo::after {
  content: '';
  position: absolute;
  left: 50%;
  width: 260%;
  height: 260%;
  transform: translate(-50%, -50%) rotate(0deg);
  transition: top 560ms var(--ease-out);
}

/* Radii below 50% are what make the edge move: a 42%-rounded square reaches
   ~4.6px further along its diagonal than along its side, so spinning it drags
   that difference across the opening as a swell. 38% swells harder still.

   The two waves now carry opposite ends of the palette — this one pink, the
   other blue — and they run at 5s and 9s in opposite directions, so the pair
   never repeats the same crossing twice and the ball's colour keeps shifting
   without anything fading.

   The purple has to live INSIDE each gradient rather than being produced by
   blending the two, and that is forced by the goo filter, not a preference.
   `#me-goo` thresholds alpha at `0 0 0 16 -7`: anything below a≈0.44 is erased
   outright and anything above is driven back to fully opaque. So a
   part-transparent wave laid over its neighbour would either disappear or come
   back solid — there is no partial coverage to mix through. Both waves stay
   opaque, and each ramps toward the middle of the palette so the meeting edge
   still reads as a transition rather than a hard seam. */
.me__goo::before {
  top: 100px;
  border-radius: 42%;
  background: linear-gradient(160deg,
    color-mix(in srgb, var(--me-hot) 68%, #fff) 0%,
    var(--me-hot) 52%,
    var(--me-mid) 100%);
  animation: liquid-wave 5s linear infinite;
}

.me__goo::after {
  top: 106px;
  border-radius: 38%;
  background: linear-gradient(160deg,
    var(--me-mid) 0%,
    color-mix(in srgb, var(--me-cold) 74%, var(--me-mid)) 100%);
  animation: liquid-wave 9s linear infinite reverse;
}

@keyframes liquid-wave {
  from { transform: translate(-50%, -50%) rotate(0deg); }
  to   { transform: translate(-50%, -50%) rotate(360deg); }
}

/* Energy motes. They rise out of the fill, tear away from it, and shrink out.
   They SHRINK rather than fade because the filter thresholds alpha — a fading
   mote would hold full strength and then vanish in one frame, while a
   shrinking one thins its neck and pinches off, which is the behaviour that
   reads as liquid. */
.me__mote {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 21px;
  height: 21px;
  margin-left: -10.5px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--me-hot) 62%, #fff);
  transform: translate(0, 0) scale(0);
}

.me__mote--a { animation: mote-rise 7.6s ease-out infinite; }
.me__mote--b { animation: mote-rise 9.8s ease-out 3.4s infinite; --mote-x: -9px; }

/* The mote leaves the surface slowly and stays near full size well past the
   fill line, so the blur has something to bridge and the neck stretches before
   it parts. The first version rose fast and shrank early: it cleared the goo's
   reach in a couple of frames, so it read as a dot popping rather than as
   liquid tearing away. Everything here is in service of keeping the mote and
   the fill within a blur radius of each other for longer. */
@keyframes mote-rise {
  0%   { transform: translate(0, -6px) scale(.35); }
  18%  { transform: translate(calc(var(--mote-x, 8px) * .2), -13px) scale(1); }
  46%  { transform: translate(calc(var(--mote-x, 8px) * .6), -22px) scale(.95); }
  74%  { transform: translate(var(--mote-x, 8px), -33px) scale(.68); }
  100% { transform: translate(calc(var(--mote-x, 8px) * 1.25), -44px) scale(0); }
}

.me__dot-label {
  position: relative;
  z-index: 1;
  font-family: var(--display);
  font-weight: 400;
  font-size: 1rem;
  letter-spacing: .04em;
  text-shadow: 0 1px 6px rgba(0, 0, 0, .45);
  transition: transform 320ms var(--ease-out), text-shadow 320ms var(--ease);
}

/* The blob never returns to a circle, so the surface always looks unsettled. */
@keyframes liquid-morph {
  0%, 100% { border-radius: 52% 48% 45% 55% / 50% 45% 55% 50%; }
  25%      { border-radius: 44% 56% 55% 45% / 57% 52% 48% 43%; }
  50%      { border-radius: 56% 44% 48% 52% / 44% 57% 43% 56%; }
  75%      { border-radius: 47% 53% 58% 42% / 52% 46% 54% 48%; }
}

/* Breathes across most of the range, on a shorter loop than the halo, so the
   pulse is unmistakable without ever going dark enough to lose the label. */
@keyframes liquid-fade {
  0%, 100% { opacity: .44; }
  50%      { opacity: .97; }
}

/* Hover and open both drop liquid-fade from the animation list rather than
   pausing it. A paused animation still owns the property, so `opacity: 1`
   alongside `animation-play-state: paused` would be silently ignored and the
   button could sit at half opacity under the cursor. Morph keeps its duration
   from the list above; only the second entry falls away. */
.me__dot:hover,
.me__dot:focus-visible {
  transform: scale(1.12);
  opacity: 1;
  animation-name: liquid-morph;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .3),
    inset 0 0 0 1px color-mix(in srgb, var(--me-hot) 45%, transparent),
    0 0 34px 4px color-mix(in srgb, var(--me-hot) 62%, transparent);
}

/* The level rises to just past the brim, so the button reads as filling. */
.me__dot:hover .me__goo::before,
.me__dot:focus-visible .me__goo::before { top: 72px; }

.me__dot:hover .me__goo::after,
.me__dot:focus-visible .me__goo::after { top: 78px; }

.me__dot:hover .me__dot-label,
.me__dot:focus-visible .me__dot-label {
  transform: scale(1.22);
  text-shadow: 0 1px 8px rgba(0, 0, 0, .6);
}

/* --------------------------------------------------------------------------
   Open: the ball stays where it is, dimmed, and becomes the dismiss control.

   It needs no fade-out to avoid the "two objects swapping" problem the morph
   used to have, because the skin's first frame covers this rect exactly. The
   ball is not re-introduced when the panel opens — it is *uncovered*, as the
   growing skin lifts up and off it. So the only thing to animate here is the
   drop in emphasis, and that is deliberately delayed until the skin has
   cleared.
   -------------------------------------------------------------------------- */
.me__dot[aria-expanded="true"] {
  /* Quiet, but not so faint it reads as broken. .34 was the first pass and sat
     on the wrong side of that line. */
  opacity: .5;
  transform: scale(.82);
  animation-name: liquid-morph;
  transition: opacity 380ms var(--ease) 140ms,
              transform 380ms var(--ease-out) 140ms;
}

/* Full, and held full. A moving fill line inside a ball this small was the
   problem: at 54px the surface is only a few pixels of travel, so draining it
   read as the ball breaking rather than as a state change. A solid violet
   ball, dimmed, says "still here, not the subject" with no moving parts. */
.me__dot[aria-expanded="true"] .me__goo::before { top: 72px; }
.me__dot[aria-expanded="true"] .me__goo::after  { top: 78px; }

/* Hover carries the whole affordance now: the ball comes back up to strength
   and tightens slightly, which is enough to mark it as the way out. */
.me__dot[aria-expanded="true"]:hover,
.me__dot[aria-expanded="true"]:focus-visible {
  opacity: .94;
  transform: scale(.9);
}

.me__dot[aria-expanded="true"]:hover .me__goo::before,
.me__dot[aria-expanded="true"]:focus-visible .me__goo::before { top: 72px; }

.me__dot[aria-expanded="true"]:hover .me__goo::after,
.me__dot[aria-expanded="true"]:focus-visible .me__goo::after { top: 78px; }

/* The label holds still while open. Growing it is the closed state's "more of
   this" cue and would misread here. */
.me__dot[aria-expanded="true"]:hover .me__dot-label,
.me__dot[aria-expanded="true"]:focus-visible .me__dot-label {
  transform: scale(1);
}

/* Coming back. `aria-expanded` is already false by the time `is-closing` lands,
   so without this the ball falls straight through to the base rule, and two
   things go wrong there. Its transform runs 320ms against the close, so the
   ball is still easing outward after the panel has gone. And `liquid-fade`
   re-enters the animation list on the first frame and restarts at its own 0%,
   snapping opacity to .44 with nothing to hide it — the ball sits below the
   panel and is not covered until the very last frame of the collapse.

   So: hold the breathing off for the length of the close and walk the ball up
   to .44 by hand. That is exactly liquid-fade's 0% keyframe, so when the class
   lifts, the breathing resumes from where the transition left off. */
.me.is-closing .me__dot {
  opacity: .44;
  /* Back to resting size, and held there even under the pointer. The morph's
     last frame is the ball's *resting* 54px box (see sizeMorph in main.js), so
     a hover scale still running underneath would leave the ball poking out from
     behind the skin at the one frame the two are meant to coincide — and
     dismissing by clicking the ball is exactly when the pointer is on it. */
  transform: none;
  animation-name: liquid-morph;
  transition: opacity var(--me-close) var(--ease-out),
              transform var(--me-close) var(--ease-out),
              box-shadow var(--me-close) var(--ease);
}

/* Dismissing by clicking the ball leaves the pointer on it, and the rule above
   outranks `.me__dot:hover`, so the hover strength has to be restated here. */
.me.is-closing .me__dot:hover,
.me.is-closing .me__dot:focus-visible { opacity: 1; }

/* Breathing halo, so the dot reads as alive and clickable without a label. */
.me__halo {
  position: absolute;
  inset: -14px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(closest-side,
              color-mix(in srgb, var(--me-hot) 50%, transparent), transparent 72%);
  filter: blur(7px);
  animation: badge-pulse 5.2s ease-in-out infinite;
}

/* --------------------------------------------------------------------------
   The panel. Springs from the dot rather than fading in, and is anchored
   bottom-centre so it grows upward out of the point of light.
   -------------------------------------------------------------------------- */
/* The panel box carries no paint of its own. Everything visible lives on
   .me__skin, which is what morphs — that separation is what lets a zoomed
   photo overhang the top edge without the morph's clip cutting it off. */
.me__panel {
  position: absolute;
  bottom: calc(100% + 20px);
  left: 50%;
  z-index: 60;
  width: min(340px, 88vw);
  margin-left: calc(min(340px, 88vw) / -2);
  /* Top padding is set by the tallest overhang, not by the text: the crown
     bubble rides 38px above the photo box, and anything less than that would
     push it out past the skin's upper edge with nothing behind it. */
  padding: 2.4rem 1.5rem 1.5rem;
  text-align: center;
}

.me__panel[hidden] { display: none; }

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

/* --------------------------------------------------------------------------
   The morph. The skin is the panel's own box, scaled down onto the dot and
   grown out of it — one object changing size.

   This began as a clip-path reveal, and that was the bug behind it reading as
   two objects swapping. A background only paints inside its own border box,
   and at t=0 the clip region sat *below* the panel's bottom edge, over empty
   space. There was nothing there to reveal, so the ball faded out against a
   blank rect and the panel materialised separately. Scaling the real box puts
   paint everywhere the eye is actually looking.

   The scale factors come from main.js measuring both rects on open, because
   the panel's height depends on its content and on the breakpoint. The values
   here are the fallback for when the script never runs.
   -------------------------------------------------------------------------- */
/* --------------------------------------------------------------------------
   The profile component's own palette. Scoped to `.me` so only the ball and
   the panel it grows into speak this brighter language. Pink carries light,
   blue carries depth, and purple bridges the two.
   -------------------------------------------------------------------------- */
.me {
  --me-hot:  #ff0080;
  --me-mid:  #a349a4;
  --me-cold: #0000ff;
  /* Pure blue is nowhere near dark enough to act as shadow next to #0A0A0A —
     it is roughly nine times the luminance of the violet it replaces. The
     depth end is therefore the blue sunk toward black, and `--me-cold` itself
     is kept for places that want the colour to actually read as blue. */
  --me-sink: color-mix(in srgb, var(--me-cold) 30%, #05040e);
  /* Blue pulled a little toward the bridge, so it reads as blue rather than as
     a hole. Named because it is needed as a base for further alpha mixing, and
     nesting color-mix inside color-mix at the use site is unreadable. */
  --me-blue: color-mix(in srgb, var(--me-cold) 62%, var(--me-mid));
  /* The blue lifted to somewhere it can actually be READ. `--me-cold` is fine
     as a base for fills and glows, where it sits under an alpha and behind
     other paint, but as foreground it is hopeless: #0000ff on this panel is
     about 1.5:1, well under the 4.5:1 floor, and the contact links were using
     it raw. This is the same hue carried up to ~9:1. Used for the links and
     for the one photo ring that is deliberately not pink. */
  --me-lit:  #8fb8ff;

  --me-sx: .159;
  --me-sy: .166;
  --me-dy: 74px;
  /* One source of truth for the close. main.js reads this back off .me to time
     the unmount, so the timer that hides the panel cannot drift away from the
     animation that empties it. */
  --me-close: 280ms;
}

.me__skin {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  /* A blob, not a card. Percentages, so the shape stays proportional while the
     box is scaled down to ball size and so it can interpolate from a plain
     50% circle. */
  border-radius: 19% 22% 18% 21% / 20% 17% 22% 19%;
  /* "Glowing purple border around" everything, per the brief. */
  border: 1px solid color-mix(in srgb, var(--me-hot) 38%, transparent);
  background:
    linear-gradient(180deg,
      color-mix(in srgb, var(--me-mid) 13%, var(--surface)) 0%,
      var(--surface-lo) 74%);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--me-hot) 12%, transparent),
    0 0 34px -4px color-mix(in srgb, var(--me-mid) 62%, transparent),
    0 26px 60px -18px rgba(0, 0, 0, .8);
  transform-origin: 50% 100%;
  animation: skin-drift 17s ease-in-out infinite;
}

/* The resting shape never settles, the same way the ball's never does. */
@keyframes skin-drift {
  0%, 100% { border-radius: 19% 22% 18% 21% / 20% 17% 22% 19%; }
  33%      { border-radius: 22% 18% 21% 19% / 17% 21% 18% 22%; }
  66%      { border-radius: 18% 21% 22% 18% / 22% 19% 17% 21%; }
}

/* The ball's own colour, laid over the panel's. Fading this out IS the
   "darken to the panel background" step — two gradients cross-fading, because
   gradient-to-gradient interpolation is not something CSS will do for you.

   Anchored to the bottom rather than across the diagonal, so that while the
   box is squashed down to ball size the gradient reproduces the ball's own
   fill line at roughly 55% up. The first frame is then a filled ball rather
   than a flat violet disc standing in for one. */
.me__skin::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to top,
    color-mix(in srgb, var(--me-hot) 62%, #fff) 0%,
    var(--me-hot) 30%,
    color-mix(in srgb, var(--me-mid) 58%, var(--me-cold)) 52%,
    color-mix(in srgb, var(--surface-lo) 84%, var(--me-sink)) 62%,
    color-mix(in srgb, var(--surface-lo) 94%, transparent) 100%);
}

/* Both animations, in this order, and `backwards` rather than `both`.
   The shorthand in the base rule is replaced wholesale by this one, so listing
   only the morph here would silently drop the resting wobble for as long as the
   panel is open. And a `both` fill would then own border-radius forever, which
   kills the wobble a second time — the later entry in the list wins while it is
   active, so the morph leads during its 520ms and hands the property back the
   moment it ends. Safe only because its final keyframe resolves to exactly the
   resting radius and the identity matrix. */
.me:not(.is-closing) .me__panel:not([hidden]) .me__skin {
  animation:
    skin-drift 17s ease-in-out infinite,
    me-skin-open 520ms var(--ease-out) backwards;
}

.me:not(.is-closing) .me__panel:not([hidden]) .me__skin::before {
  animation: me-skin-cool 460ms var(--ease) both;
}

/* Closing is its own keyframe set, not the opening one played backwards.
   `reverse` looks like a free inverse and is not one — it replays the opening
   overshoot on the way down, so the panel swelled to 1.028 before it collapsed.
   A collapse has to gather inward. */
.me.is-closing .me__skin {
  animation: me-skin-close var(--me-close) var(--ease-in) forwards;
}

/* The ball's violet comes back as the box shrinks, so the last frame of the
   panel is already the ball's own colour.

   Before this rule there was nothing at all: `me-skin-cool` hangs off
   `.me:not(.is-closing)`, so the instant `is-closing` landed the selector
   stopped matching, the animation was dropped, and the overlay reverted to its
   base value — which is 1, because the base rule never states one. Measured:
   settled open it reads 0, one class change later it reads 1, with nothing in
   flight to carry it. The panel flashed violet as it left. */
.me.is-closing .me__skin::before {
  /* Lands a beat before the box does, mirroring the way the cool-down on open
     finishes 60ms inside the morph. The colour should arrive first; a panel
     that is still grey at ball size reads as a swap. */
  animation: me-skin-warm calc(var(--me-close) * .86) var(--ease) forwards;
}

/* Growth and bounce are one keyframe set, and every step lists translate
   before scale. Split across two animations the browser composes them fine,
   but a step that omits a function falls back to matrix decomposition and
   swings the element through a visibly wrong path.

   The opening radius is a plain 50% — a circle at any box size — which is what
   makes the first frame indistinguishable from the ball it grew out of. */
@keyframes me-skin-open {
  0%   {
    transform: translateY(var(--me-dy)) scale(var(--me-sx), var(--me-sy));
    border-radius: 50%;
  }
  58%  {
    transform: translateY(0) scale(1.028, 1.032);
    border-radius: 24% 27% 23% 26% / 25% 22% 27% 24%;
  }
  80%  {
    transform: translateY(0) scale(.994, .993);
    border-radius: 20% 23% 19% 22% / 21% 18% 23% 20%;
  }
  100% {
    transform: translateY(0) scale(1, 1);
    border-radius: 19% 22% 18% 21% / 20% 17% 22% 19%;
  }
}

/* Gather, then drop. The dip at 20% is anticipation — one beat of inward
   compression before the collapse, which is the *mirror* of the opening
   overshoot rather than a replay of it. Per-keyframe timing functions do the
   real work: the gather eases out so it settles into the pause, and everything
   after it eases in so the box accelerates away and is pulled into the ball.

   Same transform-function order as me-skin-open, for the same reason — a step
   that omits one falls back to matrix decomposition and swings through a
   visibly wrong path. Radius ends at a plain 50% so the final frame is a
   circle at ball size, indistinguishable from the ball it lands on. */
@keyframes me-skin-close {
  0%   {
    transform: translateY(0) scale(1, 1);
    border-radius: 19% 22% 18% 21% / 20% 17% 22% 19%;
    animation-timing-function: var(--ease-out);
  }
  24%  {
    /* Y squashes harder than X, and the origin is the bottom edge, so the
       panel squats *toward* the ball rather than shrinking in place — about
       15px of drop against 11px of narrowing at desktop size. */
    transform: translateY(0) scale(.968, .954);
    border-radius: 23% 26% 22% 25% / 24% 21% 26% 23%;
    animation-timing-function: var(--ease-in);
  }
  100% {
    transform: translateY(var(--me-dy)) scale(var(--me-sx), var(--me-sy));
    border-radius: 50%;
  }
}

@keyframes me-skin-cool {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}

@keyframes me-skin-warm {
  0%   { opacity: 0; }
  100% { opacity: 1; }
}

/* Content resolves after the liquid has mostly arrived. */
.me:not(.is-closing) .me__panel:not([hidden]) .me__content {
  animation: me-content-in 300ms var(--ease-out) 190ms backwards;
}

/* The content clears in the first half, so the box is empty before it drops.
   `reverse` is safe on this one — it is a straight two-keyframe fade with no
   overshoot in it to replay. */
.me.is-closing .me__content {
  animation: me-content-in calc(var(--me-close) * .5) var(--ease-in) reverse both;
}

@keyframes me-content-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}

/* --------------------------------------------------------------------------
   The aura. Two elongated violet fields drifting upward behind the content,
   plus a bloom that is deliberately OUTSIDE the skin's clip so the glow
   radiates past the border rather than stopping dead at it.

   Both are gradient-only. The goo filter is affordable on a 54px button but
   too expensive on a 340px panel.
   -------------------------------------------------------------------------- */
.me__aura {
  position: absolute;
  inset: -10%;
  z-index: 0;
  pointer-events: none;
  opacity: .78;
  background:
    radial-gradient(46% 34% at 32% 78%,
      color-mix(in srgb, var(--me-hot) 70%, transparent), transparent 72%),
    radial-gradient(42% 30% at 72% 88%,
      color-mix(in srgb, var(--me-blue) 74%, transparent), transparent 74%);
  animation: aura-lick 22s ease-in-out infinite;
}

.me__aura::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(34% 26% at 55% 64%,
      color-mix(in srgb, var(--me-mid) 62%, transparent), transparent 76%);
  animation: aura-lick 17s ease-in-out infinite reverse;
}

/* Slow and small. The brief is "behind the text and subtle" — this is meant to
   be noticed on the second look, not the first. */
@keyframes aura-lick {
  0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
  33%      { transform: translate3d(-3%, -6%, 0) scale(1.14); }
  66%      { transform: translate3d(3%, -3%, 0) scale(1.06); }
}

.me__bloom {
  position: absolute;
  inset: -34px -30px -26px;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  background: radial-gradient(closest-side,
    color-mix(in srgb, var(--me-hot) 42%, transparent) 0%,
    color-mix(in srgb, var(--me-mid) 24%, transparent) 52%,
    transparent 78%);
  animation: bloom-breathe 7.5s ease-in-out infinite;
}

.me:not(.is-closing) .me__panel:not([hidden]) .me__bloom {
  animation:
    bloom-in 520ms var(--ease-out) 120ms backwards,
    bloom-breathe 7.5s ease-in-out infinite;
}

/* The glow has to leave with the panel. Left alone it kept breathing at full
   strength right through the close and then went out with the [hidden], which
   reads as the light being cut rather than withdrawn.

   Breathe stays first in the list so it keeps its hold on transform; the fade
   is second and therefore wins opacity, which is the only property it names. */
.me.is-closing .me__bloom {
  animation:
    bloom-breathe 7.5s ease-in-out infinite,
    bloom-out calc(var(--me-close) * .8) var(--ease-in) forwards;
}

@keyframes bloom-in   { from { opacity: 0; } to { opacity: 1; } }

/* No `from`: the implicit 0% keyframe is built from the underlying value, so
   the fade starts wherever the breathe happens to be rather than jumping to a
   fixed 1 first. */
@keyframes bloom-out  { to { opacity: 0; } }

@keyframes bloom-breathe {
  0%, 100% { opacity: .78; transform: scale(1); }
  50%      { opacity: 1;   transform: scale(1.05); }
}

/* Photo cluster ------------------------------------------------------------ */

.me__shots {
  position: relative;
  display: grid;
  place-items: center;
  /* Sized to the ears, not the portrait: the box is centred, so its height is
     what sets the dead space under the portrait and therefore the gap to the
     motto. The ears overhang the top by ~14px into the panel's own padding. */
  height: 172px;
  margin-bottom: 0;
  /* 172px of mostly empty box, and the motto is pulled up underneath it by a
     negative margin — so this container was swallowing the hover meant for the
     motto's glyphs. The photos opt back in below; keyboard focus is unaffected,
     since pointer-events has no bearing on tabbing. */
  pointer-events: none;
}

.me__portrait {
  position: relative;
  z-index: 2;
  width: 132px;
  height: 132px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  animation: portrait-in 520ms var(--ease-out) 220ms backwards;
}

.me__portrait img,
.me__bubble img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
}

.me__portrait img {
  border: 2px solid color-mix(in srgb, var(--me-hot) 58%, transparent);
  box-shadow: 0 0 22px -2px color-mix(in srgb, var(--me-mid) 75%, transparent);
}

@keyframes portrait-in {
  0%   { transform: scale(.35); opacity: 0; }
  60%  { transform: scale(1.07); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}

/* The two action shots are the only photos in the profile control. */
.me__bubble {
  position: absolute;
  z-index: 1;
  width: 88px;
  height: 88px;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1);
  animation: bubble-in 620ms var(--ease-out) backwards;
}

.me__bubble img {
  border: 2px solid color-mix(in srgb, var(--me-hot) 52%, transparent);
  box-shadow: 0 0 16px -3px color-mix(in srgb, var(--me-mid) 70%, transparent);
}

.me__bubble--sky { --tx: -94px; --ty: -56px; --rot: -13deg; animation-delay: 310ms; }
.me__bubble--run { --tx:  94px; --ty: -56px; --rot:  13deg; animation-delay: 375ms; }

@keyframes bubble-in {
  0%   { transform: translate(0, 0) rotate(0deg) scale(.2); opacity: 0; }
  62%  { transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1.16); opacity: 1; }
  100% { transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1);    opacity: 1; }
}

/* Examining a photo ---------------------------------------------------------
   Hovering (or keyboard-focusing) any shot enlarges it and pulls everything
   else back: the other photos, the panel's own text, the cards behind, and a
   full-viewport veil. Driven by :has() so it needs no JS.
   -------------------------------------------------------------------------- */

.me__shot {
  transition: transform 220ms var(--ease-out), opacity 200ms var(--ease),
              filter 200ms var(--ease);
  cursor: zoom-in;
  pointer-events: auto;   /* back on, against the container's `none` above */
}

.me__shot:focus-visible { outline-offset: 6px; }

/* A lift, not a takeover. The earlier 1.62/2.1 pair covered most of the panel
   and took 380ms to get there, which read as sluggish rather than deliberate. */
.me__bubble:hover,
.me__bubble:focus-visible {
  transform: translate(var(--tx), var(--ty)) rotate(0deg) scale(1.55);
  z-index: 4;
}

.me__portrait:hover,
.me__portrait:focus-visible { transform: scale(1.28); z-index: 4; }

/* Non-hovered shots recede. */
.me__shots:has(.me__shot:hover) .me__shot:not(:hover),
.me__shots:has(.me__shot:focus-visible) .me__shot:not(:focus-visible) {
  opacity: .22;
  filter: blur(2px);
}

/* So does the panel's text. */
.me__panel:has(.me__shot:hover) .me__motto,
.me__panel:has(.me__shot:hover) .me__name,
.me__panel:has(.me__shot:hover) .me__links {
  opacity: .12;
  transition: opacity 200ms var(--ease);
}

.me__veil {
  position: fixed;
  inset: 0;
  z-index: 50;
  background: rgba(6, 5, 11, .78);
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms var(--ease);
}

.me:has(.me__shot:hover) .me__veil,
.me:has(.me__shot:focus-visible) .me__veil { opacity: 1; }

/* Name, motto, links ------------------------------------------------------- */

/* Regular 400 with a faint white glow. The per-character wave that used to
   live here was cut — it read as a glitch rather than as charm. Hover is now
   just a lift in the glow. */
.me__name {
  margin: 0;
  font-family: var(--display);
  font-weight: 400;
  font-size: 1.4rem;
  letter-spacing: .015em;
  color: var(--text);
  text-shadow:
    0 0 16px rgba(255, 255, 255, .38),
    0 0 40px rgba(255, 255, 255, .15);
  transition: text-shadow 320ms var(--ease);
}

.me__name:hover {
  text-shadow:
    0 0 14px rgba(255, 255, 255, .6),
    0 0 34px rgba(255, 255, 255, .28);
}

/* Motto, set on an arc that mirrors the portrait's lower edge. Fades in only
   after every photo has finished arriving — the crown bubble now lands last at
   560ms delay + 720ms run = 1280ms — so the panel still resolves in order
   rather than overlapping the last photo's rise with the text. */
.me__motto {
  display: block;
  width: 100%;
  height: auto;
  margin: -14px auto .1rem;
  overflow: visible;
  animation: motto-in 380ms var(--ease-out) 1250ms backwards;
  /* The negative top margin pulls this band up under the portrait, so the SVG
     box overlaps it. Nothing here should be able to intercept a hover meant for
     the photo — only the glyphs below opt back in. */
  pointer-events: none;
}

.me__motto text {
  font-family: var(--display);
  /* 500, up from 300. Quicksand is self-hosted as a 300–700 variable face, so
     this is a real instance rather than a synthesised smear. It sits one step
     above the name's 400 without competing with it — at 13px on a curve the
     extra stem width reads as presence, not as bolding. */
  font-weight: 500;
  font-size: 13px;
  letter-spacing: .045em;
  /* Full-strength pink. The line used to sit at 80% to keep it under the name,
     but weight and fill have to move together: thickening the stems while
     leaving them washed out just makes the arc look grey-pink and muddier than
     it did at 300. The recession is now carried by size and weight alone.

     Any reduction here belongs in the FILL, not in an element opacity, and
     that is deliberate: `motto-in` above animates opacity and resolves to 1. An
     `opacity` here would be owned by that animation for its first 380ms and
     then snap to the reduced value the instant it handed the property back. */
  fill: var(--me-hot);
  /* Glyph-only hit testing on a 13px 300-weight arc is a miserable target, so
     take the bounding box where it is supported. The first declaration is the
     fallback for engines that drop the second — without it they would inherit
     the `none` above and the line could never be hovered at all. */
  pointer-events: visiblePainted;
  pointer-events: bounding-box;
  transition: fill 130ms var(--ease);
}

/* The base is already full-strength pink, so hover can no longer be "bring it
   up to full" — that would now be a no-op. It brightens instead, lifting the
   pink toward white. Still the same short hop, and still faster than the
   contact links' 220ms at Will's call: a slow ramp over this little distance
   read as sluggish. */
.me__motto text:hover { fill: color-mix(in srgb, var(--me-hot) 62%, #fff); }

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

.me__links {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: .3rem 1.15rem;
  margin-top: .95rem;
}

.me__link {
  position: relative;
  padding: .2rem 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: .82rem;
  /* Was a raw #0000ff — the one hardcoded colour left in the component, and
     unreadable against the panel. The token is the same blue lifted into
     legible range; the underline below rides on currentColor, so it follows. */
  color: var(--me-lit);
  text-decoration: none;
  cursor: pointer;
  transition: color 220ms var(--ease);
}

.me__link::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 300ms var(--ease-out);
}

.me__link:hover { color: var(--me-hot); }
.me__link:hover::after { transform: scaleX(1); }

/* Copy confirmation -------------------------------------------------------- */

.me__copy-done {
  position: absolute;
  left: 50%;
  bottom: 100%;
  translate: -50% 0;
  padding: .12rem .45rem;
  border-radius: 999px;
  font-size: .68rem;
  white-space: nowrap;
  color: var(--text);
  background: color-mix(in srgb, var(--me-mid) 70%, var(--surface));
  border: 1px solid color-mix(in srgb, var(--me-hot) 50%, transparent);
  opacity: 0;
  pointer-events: none;
}

.me__copy.is-copied .me__copy-done { animation: copied-pop 1500ms var(--ease-out); }

/* Clipboard unavailable: the bubble carries the address itself, so it has to
   sit still and stay long enough to read and select rather than popping. */
.me__copy.is-plain.is-copied .me__copy-done {
  animation: copied-hold 6000ms var(--ease-out);
  user-select: all;
  pointer-events: auto;
}

@keyframes copied-hold {
  0%        { opacity: 0; transform: translateY(4px) scale(.94); }
  6%, 92%   { opacity: 1; transform: translateY(-7px) scale(1);  }
  100%      { opacity: 0; transform: translateY(-7px) scale(1);  }
}

@keyframes copied-pop {
  0%   { opacity: 0; transform: translateY(6px)  scale(.85); }
  18%  { opacity: 1; transform: translateY(-9px) scale(1.1); }
  32%  { opacity: 1; transform: translateY(-6px) scale(1);   }
  76%  { opacity: 1; transform: translateY(-6px) scale(1);   }
  100% { opacity: 0; transform: translateY(-13px) scale(.97); }
}

.me__status {
  margin: 0;
  height: 0;
  overflow: hidden;
}

/* While the panel is open the page behind it recedes, so the spring lands
   against something quiet. */
.me-open .roster,
.me-open .marquee {
  filter: blur(2px);
  opacity: .62;
  transition: opacity 320ms var(--ease), filter 320ms var(--ease);
}

/* The cards stop responding entirely while the panel is up. A card that is
   dimmed and blurred but still pops and tilts under the cursor is claiming to
   be the target when the click will actually dismiss the panel.

   pointer-events does the whole job in one line — it kills the CSS hover
   states and the JS tilt together, since that listener is bound to the card
   itself. The explicit tilt reset covers the case where the pointer is already
   over a card at the moment the panel opens and no pointerleave arrives. */
.me-open .roster { pointer-events: none; }
.me-open .card__tilt { transform: none !important; }
/* Same case, one layer down. The deck's lift is a JS latch now (A9), and a
   latch has to be told to let go — pointer-events: none stops :hover matching
   but does not reliably deliver the pointerleave that would clear the class. */
.me-open .deck { transform: none !important; animation: none !important; }
.me-open .card { cursor: default; }

/* --------------------------------------------------------------------------
   Telegraphing the dismiss. With the pointer anywhere that is not the panel or
   the ball, the next click will close — so say so before it happens: the page
   behind lifts back toward normal and the panel eases down a touch, handing
   attention over.
   -------------------------------------------------------------------------- */
.me__panel {
  transition: transform 300ms var(--ease-out), opacity 300ms var(--ease);
}

.me-open:hover:not(:has(.me__panel:hover)):not(:has(.me__dot:hover)) .roster,
.me-open:hover:not(:has(.me__panel:hover)):not(:has(.me__dot:hover)) .marquee {
  filter: blur(1px);
  opacity: .82;
}

.me-open:hover:not(:has(.me__panel:hover)):not(:has(.me__dot:hover)) .me__panel {
  transform: scale(.975);
  opacity: .9;
}

.roster, .marquee { transition: opacity 320ms var(--ease), filter 320ms var(--ease); }

@keyframes badge-pulse {
  0%, 100% { opacity: .58; transform: scale(1);    }
  50%      { opacity: .92; transform: scale(1.09); }
}

.contact {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: .4rem 1.35rem;
}

.contact a {
  position: relative;
  text-decoration: none;
  font-size: .82rem;
  color: var(--text-dim);
  padding: .15rem 0;
  transition: color 220ms var(--ease);
}

.contact a::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 300ms var(--ease-out);
}

.contact a:hover { color: var(--violet-glow); }
.contact a:hover::after { transform: scaleX(1); }

/* The email copy button. Same behaviour as the home page's me panel — the
   handler in main.js binds any [data-copy] — but the me panel's own styling
   rides --me-* tokens that mean nothing out here, so the button borrows the
   footer's link treatment instead and only the confirmation is its own. */
.contact__copy {
  position: relative;
  padding: .15rem 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: .82rem;
  color: var(--text-dim);
  cursor: pointer;
  transition: color 220ms var(--ease);
}

/* Matches .contact a's wipe-in underline. */
.contact__copy::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 300ms var(--ease-out);
}

.contact__copy:hover { color: var(--violet-glow); }
.contact__copy:hover::after { transform: scaleX(1); }
.contact__copy:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }

/* The confirmation covers the label rather than sitting beside it, so the row
   never reflows mid-click and the neighbouring links stay put. */
.contact__copy-done {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  color: var(--accent);
  white-space: nowrap;
  pointer-events: none;
}

.contact__copy.is-copied .contact__copy-label { opacity: 0; }
.contact__copy.is-copied .contact__copy-done  { opacity: 1; }

/* When the clipboard write fails the done element holds the address itself,
   which is far wider than the label it is covering. It leaves the overlay and
   pushes the row instead — a reflow is the correct trade for text the visitor
   now has to select by hand. */
.contact__copy.is-plain .contact__copy-done {
  position: static;
  opacity: 1;
  color: var(--text);
  white-space: normal;
}

.contact__copy.is-plain .contact__copy-label { display: none; }

/* Screen-reader announcement only; the visible confirmation is the button. */
.contact__status {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* --------------------------------------------------------------------------
   8. Case study pages — §5: quieter than the homepage, readability over flair.
   -------------------------------------------------------------------------- */

.case { --accent: var(--violet-glow); }
.case.theme-bev   { --accent: var(--bev); }
.case.theme-storm { --accent: var(--storm); }
.case.theme-ygr   { --accent: var(--ygr); }

/* Centering treatment carried over from the Bev Fantasy / Into the Storm
   headers, so all three case studies open the same way. */
.case.theme-ygr .case-head__index { display: none; }
.case.theme-ygr .case-eyebrow { text-align: center; }

.case.theme-ygr .case-sub {
  max-width: none;
  text-align: center;
}

.case.theme-ygr .case-facts {
  justify-content: center;
  text-align: center;
}

.ygr-title {
  width: clamp(12rem, 24vw, 16rem);
  margin: 0 auto;
  line-height: 0;
}

.ygr-title__text {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.ygr-title__logo {
  display: block;
  width: 100%;
  height: auto;
}

.case-wrap {
  max-width: 64rem;
  margin: 0 auto;
  padding: clamp(1.25rem, 3vw, 2rem) var(--gutter) 0;
}

.case-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding-bottom: clamp(2rem, 6vw, 3.5rem);
}

.case-back {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  color: var(--text-dim);
  text-decoration: none;
  font-size: .82rem;
  transition: color 220ms var(--ease), gap 220ms var(--ease-out);
}

.case-back:hover { color: var(--text); gap: .75rem; }

.case-nav__right {
  display: flex;
  align-items: center;
  gap: .9rem;
}

/* Same arrow the footer's next-project card ends on, surfaced at the top too
   so jumping to the next case study doesn't require scrolling to the bottom. */
.case-nav__next {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--text-dim);
  text-decoration: none;
  font-size: 1.05rem;
  line-height: 1;
  transition: color 220ms var(--ease), transform 220ms var(--ease-out);
}

.case-nav__next:hover,
.case-nav__next:focus-visible { color: var(--accent); transform: translateX(3px); }

/* Accent carry-through: enough that the page you land on clearly belongs to
   the card you clicked, without breaking §5's calm. */
.case-head {
  position: relative;
  padding-bottom: clamp(1.5rem, 4vw, 2.25rem);
}

.case-head__index {
  position: absolute;
  top: -.28em;
  right: 0;
  font-family: var(--display);
  font-size: clamp(5rem, 16vw, 12rem);
  line-height: .8;
  color: transparent;
  -webkit-text-stroke: 1px color-mix(in srgb, var(--accent) 26%, transparent);
  opacity: .35;
  pointer-events: none;
  user-select: none;
}

.case-eyebrow {
  margin: 0 0 .7rem;
  color: var(--accent);
  font-size: .7rem;
  font-weight: 600;
  letter-spacing: .22em;
  text-transform: uppercase;
}

.case-title {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(2.75rem, 9vw, 5.5rem);
  letter-spacing: .02em;
  line-height: .88;
  text-transform: uppercase;
}

.case-sub {
  margin: 1.15rem 0 0;
  color: var(--text-dim);
  font-size: clamp(1rem, 1.8vw, 1.1rem);
  max-width: 32rem;
}

.case-rule { margin: clamp(1.5rem, 4vw, 2.25rem) 0 0; }

.case-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem 2.5rem;
  margin: 0;
  padding: 1.35rem 0;
  border-bottom: 1px solid var(--line);
}

.case-fact dt {
  margin: 0 0 .35rem;
  color: var(--text-dim);
  font-size: .66rem;
  font-weight: 600;
  letter-spacing: .2em;
  text-transform: uppercase;
}

.case-fact dd { margin: 0; font-size: .94rem; }

/* Live-status qualifier. Green on its own so 'internal' reads as a state of
   the thing rather than as part of its name. */
.case-fact__live { color: var(--ygr); }

/* Text stays at a readable measure; figures are allowed to break out wider. */
.case-body {
  display: grid;
  grid-template-columns:
    [full-start] minmax(0, 1fr)
    [text-start] minmax(0, var(--measure))
    [text-end]   minmax(0, 1fr)
    [full-end];
  margin-top: clamp(2.5rem, 6vw, 4rem);
}

.case-body > * { grid-column: text; }

.case-body h2 {
  grid-column: text;
  position: relative;
  margin: 3.5rem 0 1.1rem;
  font-family: var(--display);
  font-size: clamp(1.6rem, 3vw, 2.1rem);
  font-weight: 400;
  letter-spacing: .06em;
  line-height: 1;
  text-transform: uppercase;
}

.case-body h2::before {
  content: '';
  position: absolute;
  left: -1.25rem;
  top: .12em;
  bottom: .12em;
  width: 2px;
  background: var(--bar, var(--accent));
  border-radius: 2px;
}

.case-body > h2:first-child { margin-top: 0; }

.case-body h3 {
  margin: 2.5rem 0 .8rem;
  font-size: 1.02rem;
  font-weight: 600;
  letter-spacing: -.005em;
  color: var(--accent);
}

.case-body p { margin: 0 0 1.2rem; }

.case-body ul { margin: 0 0 1.2rem; padding-left: 1.15rem; }
.case-body li { margin-bottom: .55rem; }

.case-body code {
  padding: .15em .4em;
  border: 1px solid var(--line);
  border-radius: 5px;
  background: var(--surface-lo);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .88em;
}

.case-figure {
  grid-column: full;
  margin: 2.5rem 0;
}

.case-figure img {
  border-radius: 12px;
  width: 100%;
  border: 1px solid var(--line-soft);
}

/* Empty placeholder figures shouldn't render as a stray caption. */
.case-figure--empty {
  border: 1px dashed color-mix(in srgb, var(--accent) 28%, transparent);
  border-radius: 12px;
  padding: clamp(2rem, 6vw, 3.5rem) 1.5rem;
  text-align: center;
  background: color-mix(in srgb, var(--accent) 3%, transparent);
}

.case-figure figcaption {
  margin-top: .8rem;
  color: var(--text-dim);
  font-size: .8rem;
}

.case-figure--empty figcaption { margin: 0; }

.case-note {
  grid-column: text;
  margin: 3rem 0 0;
  padding-top: 1.25rem;
  border-top: 1px solid var(--line);
  color: var(--text-dim);
  font-size: .85rem;
}

/* About page — resume depth, kept inside the case-study visual language. */
.about-photo {
  width: clamp(120px, 20vw, 168px);
  height: clamp(120px, 20vw, 168px);
  margin: 0 auto 1.5rem;
}

.about-photo img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  border: 2px solid color-mix(in srgb, var(--violet-glow) 55%, transparent);
  box-shadow: 0 0 22px -2px color-mix(in srgb, var(--violet-core) 75%, transparent);
}

.about-page .case-eyebrow { text-align: center; }

.about-page .case-sub {
  max-width: none;
  text-align: center;
}

.about-page .case-facts {
  justify-content: center;
  text-align: center;
}

.about-page .case-facts a,
.about-entry a { text-underline-offset: .18em; }

.about-page .about-title::before {
  background: var(--violet-glow);
  box-shadow: 0 0 14px -3px var(--violet-glow);
}

.about-page .about-title--blue::before {
  background: var(--about-blue);
  box-shadow: 0 0 14px -3px var(--about-blue);
}

.about-page .about-title--dk { color: var(--ygr); }
.about-page .about-title--dk::before {
  background: var(--ygr);
  box-shadow: 0 0 14px -3px var(--ygr);
}

/* Independent projects / Experience break out of the narrow text measure so
   their entries can sit as columns instead of one long stack. */
.about-section--wide { grid-column: full; }

.about-entries {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 3rem;
}

.about-entries .about-entry {
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}

.about-experience-grid {
  display: grid;
  grid-template-columns: 1.3fr 1fr;
  gap: 0 3rem;
}

.about-experience-grid__side {
  display: flex;
  flex-direction: column;
}

.about-experience-grid > .about-entry,
.about-experience-grid__side .about-entry {
  border-top: 1px solid var(--line);
}

.about-experience-grid > .about-entry:first-child,
.about-experience-grid__side .about-entry:last-child {
  border-bottom: 1px solid var(--line);
}

@media (max-width: 780px) {
  .about-entries,
  .about-experience-grid { grid-template-columns: 1fr; }
}

.about-page .about-fact-link--bev,
.about-page .about-project-link--bev { color: var(--bev); }
.about-page .about-fact-link--storm,
.about-page .about-project-link--storm { color: var(--storm-gold); }
.about-page .about-fact-link,
.about-page .about-project-link { text-decoration: none; }
.about-page .about-draftkings { color: var(--ygr); }
.about-page .about-section--experience .about-entry__role { color: var(--about-blue); }
.about-page .case-sub { margin-top: 0; }

.about-section + .about-section { margin-top: clamp(3.5rem, 8vw, 5.5rem); }

.about-section > h2 { margin-top: 0; }

.about-entry {
  padding: 1.35rem 0 1.5rem;
  border-top: 1px solid var(--line);
}

.about-entry:last-child { border-bottom: 1px solid var(--line); }

.about-entry__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1.25rem 2rem;
  margin-bottom: .85rem;
}

.about-entry h3 {
  margin: 0 0 .28rem;
  color: var(--text);
  font-family: var(--display);
  font-size: clamp(1.05rem, 2vw, 1.3rem);
  font-weight: 500;
  letter-spacing: .025em;
  line-height: 1.15;
  text-transform: uppercase;
}

.about-entry h3 a { color: inherit; }

.about-page .about-entry__role,
.about-page .about-entry__meta {
  margin: 0;
  color: var(--accent);
  font-size: .82rem;
  line-height: 1.45;
}

.about-page .about-section--experience .about-entry__role--previous {
  margin-top: .15rem;
  color: #9b6dd6;
  font-style: italic;
}

.about-entry__meta {
  max-width: 16rem;
  color: var(--text-dim);
  text-align: right;
}

.about-entry > p:last-child,
.about-entry > ul:last-child { margin-bottom: 0; }

.about-section--split {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr);
  gap: clamp(2.5rem, 6vw, 5rem);
}

.about-section--split h2 { margin-top: 0; }

.about-entry--compact { border-bottom: 1px solid var(--line); }

.about-skills {
  display: flex;
  flex-wrap: wrap;
  gap: .55rem;
  padding: 0;
  list-style: none;
}

.about-skills li {
  margin: 0;
  padding: .42rem .7rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--text-dim);
  font-size: .78rem;
}

.about-contact > p { color: var(--text-dim); }

.about-contact__links {
  display: flex;
  flex-wrap: wrap;
  gap: .75rem;
  margin-top: .35rem;
}

.about-contact__link {
  display: inline-flex;
  align-items: center;
  gap: .55rem;
  min-height: 2.75rem;
  padding: .65rem .9rem;
  border: 1px solid color-mix(in srgb, var(--accent) 48%, var(--line));
  border-radius: 999px;
  color: var(--text);
  font-size: .84rem;
  font-weight: 600;
  text-decoration: none;
  transition: border-color 220ms var(--ease), background 220ms var(--ease), transform 220ms var(--ease-out);
}

.about-contact__link svg {
  width: 1.1rem;
  height: 1.1rem;
  flex: none;
  fill: currentColor;
  color: var(--accent);
}

.about-contact__link:hover {
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  transform: translateY(-2px);
}

.about-contact__link:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (max-width: 680px) {
  .about-entry__head { display: block; }
  .about-entry__meta { margin-top: .45rem; max-width: none; text-align: left; }
  .about-section--split { grid-template-columns: 1fr; }
}

.about-foot__logos {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: clamp(2rem, 6vw, 4rem);
  padding-top: clamp(2.5rem, 6vw, 4rem);
  border-top: 1px solid var(--line);
}

.about-foot__logos a {
  display: block;
  height: clamp(2.75rem, 6vw, 3.5rem);
  opacity: .78;
  transition: opacity 220ms var(--ease), transform 220ms var(--ease-out);
}

.about-foot__logos img {
  display: block;
  width: auto;
  height: 100%;
}

.about-foot__logos a:hover,
.about-foot__logos a:focus-visible {
  opacity: 1;
  transform: translateY(-3px);
}

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

/* Scaffolding markers — delete these along with the placeholder content.
   Note: one .todo is on the homepage and carries a second class, so the old
   `grep -rn 'class="todo"' work/` no longer finds everything. Use:
   grep -rn --include=*.html -E 'class="[^"]*todo|todo-mark' . */
.todo-mark {
  color: color-mix(in srgb, var(--accent) 78%, var(--text));
  font-weight: 600;
  letter-spacing: .04em;
}

.todo {
  border-left: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
  padding: .1rem 0 .1rem 1rem;
  margin: 0 0 1.2rem;
  color: var(--text-dim);
  font-size: .9rem;
}

/* Case study footer: next project + contact ------------------------------- */

.case-foot {
  max-width: 64rem;
  margin: clamp(4rem, 10vw, 7rem) auto 0;
  padding: 0 var(--gutter) clamp(3rem, 6vw, 4.5rem);
}

.case-next {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  padding: clamp(1.5rem, 4vw, 2.25rem) 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  text-decoration: none;
  transition: padding-inline 300ms var(--ease-out);
}

.case-next:hover { padding-inline: .75rem; }

.case-next__label { margin: 0 0 .4rem; }

.case-next__name {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(1.75rem, 5vw, 3rem);
  line-height: .9;
  letter-spacing: .03em;
  text-transform: uppercase;
  color: var(--next-accent, var(--text));
  transition: color 260ms var(--ease);
}

.case-next__arrow {
  flex: none;
  font-size: 1.5rem;
  color: var(--text-dim);
  transition: transform 320ms var(--ease-out), color 260ms var(--ease);
}

.case-next:hover .case-next__arrow { transform: translateX(6px); color: var(--next-accent, var(--text)); }

.case-foot .contact { margin-top: clamp(1.75rem, 4vw, 2.5rem); }

.case-foot__note {
  margin: clamp(1.75rem, 4vw, 2.5rem) 0 0;
  color: var(--text-dim);
  font-size: .82rem;
  line-height: 1.6;
}

/* --------------------------------------------------------------------------
   8b. Case study — category tabs (Gameplay / Art Design / User Feedback)

   These replace Problem/Process/Outcome as the page-level shape (open
   question #3 in the Art Design handover) — each panel gets its own
   free-form content instead of a shared linear arc. `display: contents` on
   the panel lets its children participate directly in .case-body's grid, so
   the existing `.case-body h2/p/ul` and `.case-figure` rules above keep
   working unchanged for content one level deeper than before.
   -------------------------------------------------------------------------- */

.case-tabs {
  grid-column: full;
  display: flex;
  flex-wrap: wrap;
  gap: clamp(1.5rem, 4vw, 2.75rem);
  margin: 0 0 clamp(2rem, 5vw, 3rem);
  padding-bottom: .1rem;
  border-bottom: 1px solid var(--line);
}

.case-tab {
  appearance: none;
  background: none;
  border: 0;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  padding: 0 0 .9rem;
  color: var(--text-dim);
  font-family: var(--display);
  font-size: clamp(1rem, 2vw, 1.2rem);
  letter-spacing: .03em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 220ms var(--ease), border-color 220ms var(--ease);
}

.case-tab:hover { color: var(--text); }
.case-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 6px; }

.case-tab[aria-selected="true"] {
  color: var(--text);
  border-color: var(--accent);
}

.case-panel { display: contents; }
.case-panel[hidden] { display: none; }

/* In-tab progression keeps a case study readable as a sequence while the
   category tabs remain available for visitors who want to jump around. */
.case-panel-next {
  grid-column: text;
  justify-self: end;
  display: inline-grid;
  grid-template-columns: auto auto auto;
  align-items: baseline;
  gap: .75rem;
  margin: clamp(2rem, 5vw, 3rem) 0 0;
  padding: .8rem 0;
  border: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  background: transparent;
  color: var(--text);
  font: inherit;
  text-decoration: none;
  cursor: pointer;
  transition: color 220ms var(--ease), border-color 220ms var(--ease), transform 220ms var(--ease-out);
}
.case-panel-next .micro { color: var(--text-dim); }
.case-panel-next > :nth-child(2) {
  font-family: var(--display);
  font-size: 1.15rem;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.case-panel-next > :last-child { color: var(--accent); font-size: 1.25rem; }
.case-panel-next:hover,
.case-panel-next:focus-visible { color: var(--accent); border-color: var(--accent); transform: translateX(4px); }

/* Because the panel is display:contents, its children are the case-body grid's
   items, and .case-body > * never matches them. Without this, ordinary prose
   inside a panel auto-places into the trailing gutter column. */
.case-panel > p,
.case-panel > h2,
.case-panel > h3,
.case-panel > ul,
.case-panel > ol,
.case-panel > blockquote { grid-column: text; }

.art-attribution {
  grid-column: text;
  margin: 0 0 1.5rem;
  color: var(--text-dim);
  font-size: .85rem;
  letter-spacing: .02em;
}

.its-tech-hero {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
  align-items: start;
  gap: clamp(2rem, 5vw, 4rem);
  margin: 0 0 clamp(1.5rem, 4vw, 2.5rem);
}

.its-tech-hero__copy { max-width: var(--measure); }
.its-tech-hero__copy h2 { margin-top: 0; }

.its-tech-hero__media { margin: 0; justify-self: center; }
.its-tech-hero__media img { width: 100%; }

/* The last tech scene loses its second image, so it no longer needs the
   full breathing room the other scenes get above it. */
.its-art-scene.its-art-scene--tight { margin-top: clamp(1rem, 3vw, 1.75rem); }

/* Mirrors .bev-scene__proof's small side-quote treatment; var(--accent) is
   already the page's storm teal here, so no extra color is needed. */
.its-tech-proof {
  margin: 1.4rem 0 0;
  padding-left: .9rem;
  border-left: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.55;
}

/* --------------------------------------------------------------------------
   8c. Art Design tab — before/after slider

   Two stacked images at a shared aspect ratio; the top one is clipped by
   --pos, which a full-box range input drives. No transition on the clip —
   mid-drag easing reads as lag, not polish.
   -------------------------------------------------------------------------- */

.art-slider-wrap { grid-column: full; margin: 2.5rem 0; }

.art-slider {
  position: relative;
  aspect-ratio: 16 / 9;
  max-height: 520px;
  margin: 0 auto;
  overflow: hidden;
  border-radius: 12px;
  border: 1px solid var(--line-soft);
  touch-action: pan-y;
  user-select: none;
}

.art-slider__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.art-slider__clip {
  position: absolute;
  inset: 0;
  clip-path: inset(0 calc(100% - var(--pos)) 0 0);
}

.art-slider__handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--pos);
  width: 2px;
  background: var(--text);
  opacity: .85;
  transform: translateX(-1px);
  pointer-events: none;
}

.art-slider__handle::before {
  content: '\2194';                 /* left-right arrow — a drag affordance, not decoration */
  position: absolute;
  top: 50%;
  left: 50%;
  width: 2.25rem;
  height: 2.25rem;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--accent);
  color: var(--bg);
  font-size: 1rem;
  font-weight: 700;
  transform: translate(-50%, -50%);
}

.art-slider__range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  appearance: none;
  cursor: ew-resize;
}

.art-slider__tag {
  position: absolute;
  top: .8rem;
  padding: .3rem .65rem;
  border-radius: 999px;
  background: color-mix(in srgb, var(--bg) 65%, transparent);
  color: var(--text);
  font-size: .68rem;
  font-weight: 600;
  letter-spacing: .1em;
  text-transform: uppercase;
  pointer-events: none;
}

.art-slider__tag--before { left: .8rem; }
.art-slider__tag--after  { right: .8rem; }

.art-slider-caption {
  grid-column: text;
  margin: .8rem auto 0;
  max-width: 64rem;
  text-align: center;
  color: var(--text-dim);
  font-size: .8rem;
}

/* --------------------------------------------------------------------------
   8d. Art Design tab — horizontal iteration timeline

   CSS scroll-snap with a partially-visible next card, not JS scroll-jacking —
   A8 already parked this site's mobile motion for being laggy, so the
   timeline leans on native scrolling everywhere rather than adding a second
   motion system to tune. Cards are a fixed box (object-fit: contain on a
   matted background) rather than a fixed height, because the 6 source
   images range from a tall A/B/C stack to a wide painted capsule and a
   crop-to-fill would lose real content on at least one of them.
   -------------------------------------------------------------------------- */

.art-timeline { grid-column: full; margin: 2.5rem 0 0; }

.art-timeline__track {
  display: flex;
  gap: 1rem;
  margin: 0;
  padding: .25rem clamp(.5rem, 4vw, 1rem) 1rem;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-color: var(--accent) transparent;
  scrollbar-width: thin;
}

.art-timeline__track::-webkit-scrollbar { height: 6px; }
.art-timeline__track::-webkit-scrollbar-track { background: transparent; }
.art-timeline__track::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--accent) 55%, transparent);
  border-radius: 999px;
}

.art-timeline__beat {
  flex: none;
  scroll-snap-align: start;
  width: clamp(220px, 78vw, 320px);
}

.art-timeline__beat img {
  display: block;
  width: 100%;
  height: 280px;
  object-fit: contain;
  border-radius: 10px;
  border: 1px solid var(--line-soft);
  background: var(--surface-lo);
}

.art-timeline__beat p {
  margin: .7rem 0 0;
  color: var(--text-dim);
  font-size: .82rem;
  line-height: 1.4;
}

.art-timeline__num {
  margin-right: .4em;
  color: var(--accent);
  font-family: var(--display);
  letter-spacing: .04em;
}

.art-timeline__trigger {
  appearance: none;
  background: none;
  border: 0;
  padding: 0;
  display: block;
  width: 100%;
  cursor: zoom-in;
}

.art-timeline__trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 10px; }

.art-timeline__trigger img { transition: filter 200ms var(--ease); }
.art-timeline__trigger:hover img,
.art-timeline__trigger:focus-visible img { filter: brightness(1.12); }

/* --------------------------------------------------------------------------
   8e. Into the Storm — art-direction story

   The supplied process artifacts have very different shapes, so this section
   lets each keep its natural ratio. The finished image bookends the story;
   short alternating chapters explain the decisions without hiding evidence
   behind the old horizontal timeline or lightbox.
   -------------------------------------------------------------------------- */

.case.theme-storm .case-head__index { display: none; }

/* Centering treatment carried over from the Bev Fantasy header. */
.case.theme-storm .case-eyebrow { text-align: center; }

.case.theme-storm .case-sub {
  max-width: none;
  text-align: center;
}

.case.theme-storm .case-facts {
  justify-content: center;
  text-align: center;
}

.its-title {
  width: clamp(19rem, 52vw, 38rem);
  margin: 0 auto;
  line-height: 0;
}

.its-title__text {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.its-title__logo {
  display: block;
  width: 100%;
  height: auto;
  filter: drop-shadow(0 0 28px color-mix(in srgb, var(--storm-gold) 22%, transparent));
}

.its-status-alpha {
  color: var(--storm-gold);
  text-shadow: 0 0 18px color-mix(in srgb, var(--storm-gold) 35%, transparent);
  text-underline-offset: .2em;
}

/* Inline links in body prose: gold for a person credited by name, teal for a
   plain reference link (matches the marker-teal/marker-gold split above). */
.its-link-gold {
  color: var(--storm-gold);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--storm-gold) 55%, transparent);
  text-underline-offset: .18em;
  transition: text-decoration-color 250ms var(--ease), color 250ms var(--ease);
}

.its-link-gold:hover { text-decoration-color: var(--storm-gold); }

.its-link {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--storm) 55%, transparent);
  text-underline-offset: .18em;
  transition: text-decoration-color 250ms var(--ease), color 250ms var(--ease);
}

.its-link:hover { color: var(--storm); text-decoration-color: var(--storm); }

.its-art-hero,
.its-art-finale {
  grid-column: full;
}

.its-art-hero {
  margin: 0 0 clamp(3.5rem, 8vw, 6rem);
}

.its-art-shot {
  appearance: none;
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  text-align: inherit;
  cursor: zoom-in;
}

.its-art-shot:focus-visible {
  outline: 2px solid var(--storm-gold);
  outline-offset: 4px;
  border-radius: 12px;
}

.its-art-shot img {
  transition: filter 220ms var(--ease), transform 420ms var(--ease-out);
}

.its-art-shot:hover img,
.its-art-shot:focus-visible img {
  filter: brightness(1.08);
  transform: translateY(-2px);
}

.its-art-hero img,
.its-art-frame img {
  display: block;
  width: 100%;
  height: auto;
}

.its-art-hero img {
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-lo);
  box-shadow:
    0 22px 58px -26px rgba(0, 0, 0, .92),
    0 0 34px -22px color-mix(in srgb, var(--storm) 52%, transparent);
}

.its-art-hero figcaption,
.its-art-frame figcaption {
  margin: .75rem 0 0;
  color: var(--text-dim);
  font-size: .82rem;
  line-height: 1.5;
}

.its-art-scene {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 23rem);
  gap: clamp(1.5rem, 4vw, 3.25rem);
  align-items: center;
  margin: clamp(3rem, 8vw, 6rem) 0;
}

.its-art-scene--flip {
  grid-template-columns: minmax(0, 23rem) minmax(0, 1.5fr);
}

.its-art-scene--flip .its-art-scene__media { order: 2; }
.its-art-scene__copy[data-reveal] { transition-delay: 110ms; }
.its-art-scene__copy > :first-child { margin-top: 0; }
.its-art-scene__copy > :last-child { margin-bottom: 0; }
.its-art-scene__copy p { margin: 0 0 1.1rem; }

.its-art-scene__copy h2,
.its-art-finale__copy h2 {
  position: relative;
  margin: 0 0 1rem;
  font-family: var(--display);
  font-size: clamp(1.5rem, 2.6vw, 1.95rem);
  font-weight: 400;
  letter-spacing: .06em;
  line-height: 1.05;
  text-transform: uppercase;
}

.its-art-scene__copy h2::before,
.its-art-finale__copy h2::before {
  content: '';
  position: absolute;
  top: .1em;
  bottom: .1em;
  left: -1.1rem;
  width: 3px;
  border-radius: 2px;
  background: var(--bar, var(--storm));
  box-shadow: 0 0 14px -3px var(--bar, var(--storm));
}

.case.theme-storm .marker-teal { --bar: var(--storm); }
.case.theme-storm .marker-gold { --bar: var(--storm-gold); }

.its-art-pair,
.its-art-stack {
  display: grid;
  gap: clamp(.8rem, 2vw, 1.25rem);
  align-items: center;
}

.its-art-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.its-art-stack { grid-template-columns: minmax(0, .72fr) minmax(0, 1.28fr); }

.its-art-frame {
  min-width: 0;
  margin: 0;
}

.its-art-frame img {
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface-lo);
  box-shadow: 0 16px 42px -25px rgba(0, 0, 0, .95);
}

.its-art-finale {
  margin: clamp(4rem, 10vw, 7rem) 0 0;
  padding-top: clamp(1.5rem, 4vw, 2.5rem);
  border-top: 1px solid var(--line);
}

/* Centered and full-width so the closing statement reads as a headline over
   the comparison slider, rather than a narrow column beside it. */
.its-art-finale__copy {
  max-width: 34rem;
  margin: 0 auto clamp(1.75rem, 4vw, 2.5rem);
  text-align: center;
}

/* Centered heading trades the side tick every other scene uses for a
   centered underline, and carries the marker color on the text itself so
   "teal" or "gold" still reads without a bar to hang it on. */
.its-art-finale__copy h2 {
  color: var(--bar, var(--storm));
}

.its-art-finale__copy h2::before {
  display: none;
}

.its-art-finale__copy p { margin: 0; }

.its-art-stack .its-art-frame:first-child {
  grid-row: 1 / span 2;
}

/* ---- Before/after comparison slider ---------------------------------- */

.its-compare { margin: 0; }

.its-compare__frame {
  position: relative;
  aspect-ratio: 2560 / 1388;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-lo);
  box-shadow:
    0 22px 58px -26px rgba(0, 0, 0, .92),
    0 0 34px -22px color-mix(in srgb, var(--storm) 52%, transparent);
}

.its-compare__layer {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.its-compare__layer--after {
  clip-path: inset(0 calc(100% - var(--pos, 58%)) 0 0);
}

.its-compare__img {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: none;
}


.its-compare__handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--pos, 50%);
  width: 2px;
  margin-left: -1px;
  background: var(--storm-gold);
  box-shadow: 0 0 14px -2px color-mix(in srgb, var(--storm-gold) 70%, transparent);
  pointer-events: none;
}

.its-compare__grip {
  position: absolute;
  top: 50%;
  left: 50%;
  display: grid;
  place-items: center;
  width: 2.4rem;
  height: 2.4rem;
  transform: translate(-50%, -50%);
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-lo);
  box-shadow: 0 8px 22px -10px rgba(0, 0, 0, .85);
  color: var(--storm-gold);
  font-size: .9rem;
}

.its-compare__range {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  appearance: none;
  background: none;
  cursor: ew-resize;
}

.its-compare__range::-webkit-slider-runnable-track { background: transparent; }
.its-compare__range::-webkit-slider-thumb {
  appearance: none;
  width: 2.4rem;
  height: 100%;
  opacity: 0;
}
.its-compare__range::-moz-range-track { background: transparent; border: none; }
.its-compare__range::-moz-range-thumb {
  width: 2.4rem;
  height: 100%;
  opacity: 0;
  border: none;
}

.its-compare__range:focus-visible + .its-compare__handle .its-compare__grip {
  outline: 2px solid var(--storm-gold);
  outline-offset: 3px;
}

@media (max-width: 60rem) {
  .its-art-scene,
  .its-art-scene--flip {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(1.1rem, 4vw, 1.75rem);
    margin: clamp(2.5rem, 8vw, 4rem) 0;
  }

  .its-tech-hero {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(1.5rem, 5vw, 2.25rem);
    margin: 0 0 clamp(1.5rem, 4vw, 2rem);
  }
  .its-tech-hero__media { order: -1; max-width: 16rem; width: 100%; }

  .its-art-scene--flip .its-art-scene__media { order: 0; }
  .its-art-scene__copy h2::before,
  .its-art-finale__copy h2::before { left: -.75rem; }
}

@media (max-width: 40rem) {
  .its-title { width: min(100%, 27rem); }
  .its-art-pair,
  .its-art-stack { grid-template-columns: minmax(0, 1fr); }
  .its-art-stack .its-art-frame:first-child { grid-row: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .its-art-shot img,
  .its-art-shot:hover img,
  .its-art-shot:focus-visible img {
    transform: none;
    transition: none;
  }
}

/* --------------------------------------------------------------------------
   8f. User Feedback tab

   The feedback story uses the same screenshot-led grammar as the other case
   studies, but its evidence has three shapes: the player-facing form, a
   report-to-change pair, and compact analytics captures. Keeping that visual
   hierarchy in CSS prevents the supporting Discord images from overwhelming
   the in-game result they helped produce.
   -------------------------------------------------------------------------- */

/* The form and the Discord message it produces lead the tab together, at a
   matched size, rather than the form running full width on its own. */
.its-feedback-top {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: start;
  gap: clamp(1.25rem, 3vw, 2rem);
  margin: 0 0 clamp(1.5rem, 4vw, 2.5rem);
}

.its-feedback-top figure { margin: 0; }

/* The feedback-form shot is shorter than the Discord capture beside it;
   center it in the row instead of leaving it stranded at the top. */
.its-feedback-top figure:first-child { align-self: center; }

.its-feedback-top figcaption,
.its-feedback-clarity figcaption,
.its-feedback-analytics figcaption {
  margin: .8rem 0 0;
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.5;
}

/* Frictionless Feedback and Transparent Iteration read as a matched pair,
   the same grammar the intro spread on the Bev Fantasy page uses. */
.its-feedback-intro {
  grid-column: full;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(2rem, 6vw, 5rem);
  margin: clamp(1rem, 3vw, 2rem) 0 0;
}

.its-feedback-intro__col { max-width: var(--measure); }
.its-feedback-intro__col h2 { grid-column: auto; margin-top: 0; }
.its-feedback-intro__col > :last-child { margin-bottom: 0; }

.its-feedback-shot {
  display: block;
  width: 100%;
  padding: 0;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-lo);
  box-shadow:
    0 18px 50px -22px rgba(0, 0, 0, .9),
    0 0 0 1px var(--line-soft) inset;
  transition:
    transform 420ms var(--ease-out),
    box-shadow 420ms var(--ease-out),
    border-color 420ms var(--ease);
}

button.its-feedback-shot { cursor: zoom-in; }
.its-feedback-shot img { display: block; width: 100%; height: auto; }

.its-feedback-shot:hover,
.its-feedback-shot:focus-visible {
  transform: translateY(-5px);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  box-shadow:
    0 26px 64px -24px rgba(0, 0, 0, .95),
    0 0 46px -12px color-mix(in srgb, var(--accent) 38%, transparent),
    0 0 0 1px var(--line-soft) inset;
}

.case-panel > .its-feedback-flow {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(15rem, 1.2fr);
  gap: 1px;
  margin: clamp(2rem, 5vw, 3rem) 0;
  border: 1px solid var(--line);
  border-radius: 14px;
  overflow: hidden;
  background: var(--line);
}

.its-feedback-flow__step,
.its-feedback-flow__outputs {
  margin: 0;
  padding: clamp(1.15rem, 3vw, 1.65rem);
  background: var(--surface-lo);
}

.its-feedback-flow__num {
  display: block;
  margin: 0 0 .65rem;
  color: var(--accent);
  font-family: var(--display);
  font-size: .75rem;
  letter-spacing: .12em;
}

.its-feedback-flow h3 {
  margin: 0 0 .55rem;
  color: var(--text);
  font-family: var(--display);
  font-size: 1rem;
  font-weight: 400;
  letter-spacing: .05em;
  text-transform: uppercase;
}

.its-feedback-flow p {
  margin: 0;
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.55;
}

.its-feedback-flow__outputs {
  display: grid;
  gap: .75rem;
  padding-left: clamp(1.15rem, 3vw, 1.65rem);
  list-style: none;
}

.its-feedback-flow__outputs li {
  display: grid;
  grid-template-columns: 6.75rem minmax(0, 1fr);
  gap: .75rem;
  margin: 0;
  font-size: .82rem;
  line-height: 1.45;
}

.its-feedback-flow__outputs b {
  color: var(--text);
  font-weight: 600;
}

.its-feedback-flow__outputs span { color: var(--text-dim); }

.its-feedback-scene {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1.45fr) minmax(0, 24rem);
  gap: clamp(1.5rem, 4vw, 3.25rem);
  align-items: center;
  margin: clamp(3rem, 8vw, 6rem) 0;
}

.its-feedback-scene--flip {
  grid-template-columns: minmax(0, 24rem) minmax(0, 1.45fr);
}

.its-feedback-scene--flip .its-feedback-scene__media { order: 2; }
.its-feedback-scene__copy[data-reveal] { transition-delay: 110ms; }
.its-feedback-scene__copy > :first-child { margin-top: 0; }
.its-feedback-scene__copy > :last-child { margin-bottom: 0; }
.its-feedback-scene__copy p { margin: 0 0 1.1rem; }

.its-feedback-scene__copy h2 {
  position: relative;
  margin: 0 0 1rem;
  font-family: var(--display);
  font-size: clamp(1.5rem, 2.6vw, 1.95rem);
  font-weight: 400;
  letter-spacing: .06em;
  line-height: 1.05;
  text-transform: uppercase;
}

.its-feedback-scene__copy h2::before {
  content: '';
  position: absolute;
  top: .1em;
  bottom: .1em;
  left: -1.1rem;
  width: 3px;
  border-radius: 2px;
  background: var(--bar, var(--accent));
  box-shadow: 0 0 14px -3px var(--bar, var(--accent));
}

.its-feedback-clarity {
  display: grid;
  grid-template-columns: minmax(0, .75fr) minmax(0, 1.25fr);
  align-items: end;
  gap: clamp(.75rem, 2vw, 1.3rem);
}

.its-feedback-clarity figure,
.its-feedback-analytics figure { margin: 0; }

/* Smaller and flush with the column's outer edge, so the two charts read as
   supporting evidence for "Data driven design" rather than competing with it
   for weight. */
.its-feedback-analytics {
  display: grid;
  gap: clamp(.9rem, 2vw, 1.25rem);
  max-width: 82%;
  margin-left: auto;
}

.its-feedback-analytics figcaption { font-size: .78rem; }

.its-feedback-disclosure {
  grid-column: text;
  margin: clamp(3rem, 7vw, 5rem) 0 0;
  padding: 1.25rem 0 0 1rem;
  border-top: 1px solid var(--line);
  border-left: 2px solid var(--line-tick);
}

.its-feedback-disclosure h2 {
  margin: 0 0 1rem;
  font-size: clamp(1.25rem, 2.2vw, 1.55rem);
}

.its-feedback-disclosure h2::before { content: none; }

.its-feedback-disclosure p {
  color: var(--text-dim);
  font-size: .86rem;
  line-height: 1.6;
}

@media (prefers-reduced-motion: reduce) {
  .its-feedback-shot,
  .its-feedback-shot:hover,
  .its-feedback-shot:focus-visible { transform: none; transition: none; }
}

@media (max-width: 60rem) {
  .case-panel > .its-feedback-flow { grid-template-columns: minmax(0, 1fr); }

  .its-feedback-scene,
  .its-feedback-scene--flip { grid-template-columns: minmax(0, 1fr); }
  .its-feedback-scene { gap: clamp(1.1rem, 4vw, 1.75rem); margin: clamp(2.5rem, 8vw, 4rem) 0; }
  .its-feedback-scene--flip .its-feedback-scene__media { order: 0; }
  .its-feedback-scene__copy h2::before { left: -.75rem; }

  .its-feedback-top { grid-template-columns: minmax(0, 1fr); }

  .its-feedback-intro { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .its-feedback-intro__col + .its-feedback-intro__col h2 { margin-top: 3rem; }

  .its-feedback-analytics { max-width: none; margin-left: 0; }
}

@media (max-width: 36rem) {
  .its-feedback-clarity { grid-template-columns: minmax(0, 1fr); }
  .its-feedback-clarity__report { width: min(78%, 20rem); }
  .its-feedback-flow__outputs li { grid-template-columns: minmax(0, 1fr); gap: .15rem; }
}

/* --------------------------------------------------------------------------
   8g. Shared case-study lightbox

   Click a beat, see it large with its caption reset in bigger type. The
   panel gets a FIXED width (not sized to whatever image happens to be
   showing) specifically so prev/next/close can sit at consistent absolute
   positions inside it — position: fixed would be simpler, but the panel's
   own open/close scale transform creates a containing block that breaks
   fixed positioning for anything inside it.
   -------------------------------------------------------------------------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: grid;
  place-items: center;
  padding: clamp(1rem, 5vw, 3rem);
  opacity: 0;
  visibility: hidden;
  transition: opacity 240ms var(--ease), visibility 0s linear 240ms;
}

.lightbox.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 240ms var(--ease);
}

.lightbox__backdrop {
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: blur(6px);
}

.lightbox__panel {
  position: relative;
  z-index: 1;
  width: min(92vw, 1100px);
  max-height: 90vh;
  padding: 0 clamp(2.5rem, 6vw, 3.5rem);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
  transform: scale(.96);
  transition: transform 240ms var(--ease-out);
}

.lightbox.is-open .lightbox__panel { transform: scale(1); }

/* min-height:0 lets the stage shrink below its content inside the flex column
   instead of pushing the caption off the bottom of the panel. */
.lightbox__panel > .lightbox__stage { min-height: 0; }

/* The stage is the window the image is zoomed and panned inside, so the frame
   that used to sit on the image itself lives here — the image can now be
   larger than what you see of it, and a border tracking its edge would leave
   the viewport. touch-action is off because every gesture over the stage is
   ours; the page behind is scroll-locked anyway. */
.lightbox__stage {
  position: relative;
  max-width: 100%;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: 12px;
  border: 1px solid var(--line-soft);
  background: var(--surface-lo);
  cursor: zoom-in;
  touch-action: none;
}

.lightbox__stage.is-zoomed   { cursor: grab; }
.lightbox__stage.is-grabbing { cursor: grabbing; }

.lightbox__img {
  max-width: 100%;
  max-height: 68vh;
  width: auto;
  height: auto;
  object-fit: contain;
  transform-origin: 50% 50%;
  transition: transform 180ms var(--ease-out);
  user-select: none;
  -webkit-user-drag: none;
}

/* While a finger or the mouse is down the transform must track the input
   exactly; a transition here would make the image lag behind the hand. */
.lightbox__stage.is-grabbing .lightbox__img { transition: none; }

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

.lightbox__caption {
  margin: 0;
  max-width: 46rem;
  text-align: center;
  color: var(--text);
  font-size: clamp(1.05rem, 2.2vw, 1.35rem);
  line-height: 1.5;
}

.lightbox__close,
.lightbox__nav {
  appearance: none;
  border: 1px solid var(--line-soft);
  border-radius: 50%;
  background: color-mix(in srgb, var(--bg) 70%, transparent);
  color: var(--text);
  width: 2.75rem;
  height: 2.75rem;
  display: grid;
  place-items: center;
  font-size: 1.3rem;
  line-height: 1;
  cursor: pointer;
  transition: background 200ms var(--ease), border-color 200ms var(--ease), color 200ms var(--ease);
}

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

.lightbox__close:focus-visible,
.lightbox__nav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.lightbox__close {
  position: absolute;
  top: 0;
  right: clamp(.5rem, 2vw, 1rem);
}

.lightbox__nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

/* display:grid above would otherwise beat the UA rule for [hidden]. */
.lightbox__nav[hidden] { display: none; }

.lightbox__nav--prev { left: 0; }
.lightbox__nav--next { right: 0; }

body.lightbox-open { overflow: hidden; }

/* --------------------------------------------------------------------------
   9. 404
   -------------------------------------------------------------------------- */

.notfound {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-content: center;
  text-align: center;
  padding: 2rem;
  position: relative;
  overflow: hidden;
}

.notfound > * { position: relative; }

.notfound h1 {
  font-family: var(--display);
  font-size: clamp(5rem, 22vw, 12rem);
  margin: 0;
  line-height: .85;
  letter-spacing: .04em;
  color: var(--text);
}

.notfound p { color: var(--text-dim); }

.notfound a { color: var(--violet-glow); }

/* --------------------------------------------------------------------------
   10. Responsive
   -------------------------------------------------------------------------- */

/* Cards get cramped well before they stack — pull the stagger back first. */
@media (max-width: 1180px) {
  .card--storm { margin-top: clamp(1.5rem, 4vw, 3rem); }
  .card--ygr   { margin-top: clamp(.25rem, 1vw, 1rem); }
}

@media (max-width: 1000px) {
  .roster { gap: clamp(1.25rem, 3.5vw, 2.5rem); }
  .card   { width: clamp(240px, 34vw, 380px); }
}

@media (max-width: 820px) {
  .roster {
    flex-direction: column;
    align-items: center;
    gap: clamp(3.5rem, 9vw, 5rem);
    padding-top: clamp(3rem, 8vw, 4rem);
  }
  .card         { width: min(520px, 88vw); }
  .card--storm  { margin-top: 0; }
  .card--ygr    { margin-top: 0; }
  .case-head__index { font-size: clamp(4rem, 18vw, 7rem); opacity: .22; }
}

@media (max-width: 600px) {
  .card__foot      { flex-direction: column; align-items: flex-start; gap: .7rem; }
  .case-body h2::before { left: -.75rem; }
  .case-next       { flex-wrap: wrap; }
}

@media (max-width: 420px) {
  .card        { width: 100%; }
}

/* Short viewports: the homepage is a composition, not a scroll. Two tiers —
   the first covers ordinary laptops once browser chrome is subtracted, the
   second covers genuinely cramped windows and phone landscape. */
@media (max-height: 900px) and (min-width: 821px) {
  .marquee        { padding-top: clamp(1rem, 2vw, 1.5rem); }
  .roster         { padding-block: clamp(1rem, 2vw, 1.5rem) clamp(1rem, 2vw, 1.25rem); }
  .card           { width: clamp(230px, 24vw, 360px); }
  .card--storm    { margin-top: clamp(1rem, 2.5vw, 2rem); }
  .card--ygr      { margin-top: clamp(.25rem, 1vw, .75rem); }
  .site-foot      { padding-bottom: clamp(1rem, 2vw, 1.5rem); }
}

@media (max-height: 700px) and (min-width: 821px) {
  .card           { width: clamp(240px, 28vw, 370px); }
  .card--storm    { margin-top: clamp(.75rem, 2vh, 1.5rem); }
}

/* Touch: :hover sticks after a tap, and hover-only affordances mislead. */
@media (hover: none) {
  .card:hover .card__float      { animation-play-state: running; }
  .card:hover .card__inner      { transform: none; }
  .card:hover .card__art > img  { transform: scale(1.02); }
  .card:hover .card__sheen      { opacity: 0; }
  /* :hover sticks after a tap, so a tapped deck would stay bounced with no way
     to put it back. It sits at rest instead — and `animation: none` matters,
     or the pop would still run once on the tap and hold its last frame. */
  .deck:has(.deck__shot:hover),
  .deck.is-lifted,
  .deck.is-popping              { transform: none; animation: none; }
  /* main.js never adds these on a coarse pointer (same `fine` gate as the
     tilt), but the override lists them so the rule is true of the markup
     rather than true only because of a condition living in another file. */
  .card.is-popping .card__inner { animation: none; }
  .card:active .card__inner     { transform: scale(.985); transition-duration: 120ms; }
  .card__tilt                   { transform: none !important; }
  .case-next:hover              { padding-inline: 0; }
}

/* --------------------------------------------------------------------------
   11. Reduced motion — §4 requires this.
   Idle drift, tilt, float and component motion are removed.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }

  /* The panel still opens and every photo still lands in its final place —
     it simply arrives rather than springing. */
  .me__halo        { animation: none; }
  .me__dot         { animation: none; opacity: .85; }
  /* The fill stays painted and simply stops sloshing. */
  .me__goo::before,
  .me__goo::after { animation: none; }
  .me__mote        { animation: none; }
  /* No morph: the skin is simply already the panel, at the panel's colour, and
     the aura is painted but still. */
  .me__skin        { animation: none; transform: none; }
  .me__skin::before { animation: none; opacity: 0; }
  .me__content     { animation: none; }
  .me__aura        { animation: none; }
  .me__bloom       { animation: none; opacity: .85; }
  .me__panel,
  .me__portrait,
  .me__motto,
  .me__bubble      { animation: none; }
  .me__bubble      { opacity: 1; }
  /* The zoom is an intentional inspect action, so it stays — just without the
     easing ramp. */
  .me__shot        { transition: none; }
  .me-open .roster,
  .me-open .marquee { filter: none; opacity: .7; }

  .card__float               { animation: none; }
  .card__tilt                { transform: none !important; }
  /* No pop, no squash. The hover still reads through border, glow and sheen,
     which are colour changes rather than motion. */
  .card:hover .card__inner,
  .card:focus-visible .card__inner,
  .card.is-popping .card__inner { transform: none; animation: none; }
  /* The deck was missed when A5 landed. Reduced motion collapses the
     transition to ~0, so the lift became a hard snap that chattered every time
     a phone edge crossed the pointer — motion, and the twitchy kind. It sits
     still now, exactly like the card above it. */
  .deck:has(.deck__shot:hover),
  .deck.is-lifted,
  .deck.is-popping             { transform: none; animation: none; }
  .card:hover .card__art > img { transform: scale(1.02); }
}

/* --------------------------------------------------------------------------
   11. Yggdrasil case study — full-bleed alternating scenes.

   The other two case pages are text-led: prose at --measure, the occasional
   figure. This one is screenshot-led, because the product's whole argument is
   visual and the screens are dense enough that shrinking them to 34rem throws
   away the point. So the wrap widens, each beat becomes an image and a short
   column of prose side by side, and the sides alternate down the page.
   -------------------------------------------------------------------------- */

.case-wrap--wide { max-width: 84rem; }

/* The head still reads as prose, so it keeps the narrow measure even though
   its container no longer enforces one. */
.case-wrap--wide .case-sub { max-width: var(--measure); }

/* Reveal on scroll. The class is added by an IntersectionObserver in main.js;
   under reduced motion it is added immediately and these transitions never
   get a chance to run. */
/* Gated on .js, which the inline head script sets. If main.js never runs there
   is no observer to un-hide anything, so the content must not start hidden. */
.js [data-reveal] {
  opacity: 0;
  transform: translateY(20px);
  filter: blur(6px);
  transition:
    opacity   700ms var(--ease-out),
    transform 700ms var(--ease-out),
    filter    700ms var(--ease-out);
}

.js [data-reveal].is-in {
  opacity: 1;
  transform: none;
  filter: blur(0);
}

/* Staggers the copy in behind its image so the pair does not arrive as one
   flat slab. */
.ygr-scene__copy[data-reveal] { transition-delay: 110ms; }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal] { opacity: 1; transform: none; filter: none; transition: none; }
}

/* ---- Scene ---------------------------------------------------------------- */

.ygr-scene {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1.45fr) minmax(0, 24rem);
  gap: clamp(1.5rem, 4vw, 3.25rem);
  align-items: center;
  margin: clamp(3rem, 8vw, 6rem) 0;
}

/* Flipped beats put the image on the right. The column widths have to swap
   with it, otherwise `order` just drops the image into the narrow column.
   Source order stays image-then-copy on every scene, so the small-screen stack
   and the reading order never depend on which side won. */
.ygr-scene--flip {
  grid-template-columns: minmax(0, 24rem) minmax(0, 1.45fr);
}
.ygr-scene--flip .ygr-scene__media { order: 2; }

.ygr-scene__copy > :first-child { margin-top: 0; }
.ygr-scene__copy > :last-child  { margin-bottom: 0; }

.ygr-scene__copy h2 {
  position: relative;
  margin: 0 0 1rem;
  font-family: var(--display);
  font-size: clamp(1.5rem, 2.6vw, 1.95rem);
  font-weight: 400;
  letter-spacing: .06em;
  line-height: 1.05;
  text-transform: uppercase;
}

.ygr-scene__copy h2::before {
  content: '';
  position: absolute;
  left: -1.1rem;
  top: .1em;
  bottom: .1em;
  width: 3px;
  background: var(--accent);
  border-radius: 2px;
}

/* The product colour-codes teams, and the left rail's key is the first thing
   anyone reads. These are those nine swatches, sampled straight out of the app
   and kept in the rail's own order.

   Each heading takes the next one, so reading down the page walks the legend
   one team at a time. --bar falls back to the accent, which means a heading
   added later without a .hue-* class still gets a mark rather than nothing.

   Scoped to .theme-ygr: the other case studies keep their single accent. */
.case.theme-ygr {
  --hue-1: #68B81C;   /* All Realms          */
  --hue-2: #58BFF3;   /* Bifröst Tollkeepers */
  --hue-3: #F16692;   /* Brokkr's Forge      */
  --hue-4: #F0BD52;   /* Heimdall's Watch    */
  --hue-5: #E46E0E;   /* Gjallarbrú Passage  */
  --hue-6: #B287F5;   /* The Thing Council   */
  --hue-7: #F06363;   /* Huginn and Muninn   */
  --hue-8: #3ECAB8;   /* Mímir's Well        */
  --hue-9: #5DAD10;   /* The High Seat       */
}

.case.theme-ygr .hue-1 { --bar: var(--hue-1); }
.case.theme-ygr .hue-2 { --bar: var(--hue-2); }
.case.theme-ygr .hue-3 { --bar: var(--hue-3); }
.case.theme-ygr .hue-4 { --bar: var(--hue-4); }
.case.theme-ygr .hue-5 { --bar: var(--hue-5); }
.case.theme-ygr .hue-6 { --bar: var(--hue-6); }
.case.theme-ygr .hue-7 { --bar: var(--hue-7); }
.case.theme-ygr .hue-8 { --bar: var(--hue-8); }
.case.theme-ygr .hue-9 { --bar: var(--hue-9); }

.case.theme-ygr .case-body h2::before,
.case.theme-ygr .ygr-scene__copy h2::before {
  width: 3px;
  background: var(--bar, var(--accent));
  box-shadow: 0 0 14px -3px var(--bar, var(--accent));
}

.ygr-scene__copy p { margin: 0 0 1.1rem; }

/* ---- Shot ----------------------------------------------------------------- */

/* A screenshot of a dark, self-lit interface sitting on a near-black page has
   no edge of its own. The border and the accent-tinted bloom give it one
   without putting a bright frame around it. */
.ygr-shot {
  display: block;
  width: 100%;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-lo);
  overflow: hidden;
  box-shadow:
    0 18px 50px -22px rgba(0, 0, 0, .9),
    0 0 0 1px var(--line-soft) inset;
  transition:
    transform  420ms var(--ease-out),
    box-shadow 420ms var(--ease-out),
    border-color 420ms var(--ease);
}

/* Every shot on the page opens the lightbox, so every one of them is a
   button and says so on hover. */
button.ygr-shot { cursor: zoom-in; }

.ygr-shot img {
  display: block;
  width: 100%;
  height: auto;
}

.ygr-shot:hover,
.ygr-shot:focus-visible {
  transform: translateY(-5px);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  box-shadow:
    0 26px 64px -24px rgba(0, 0, 0, .95),
    0 0 46px -12px color-mix(in srgb, var(--accent) 38%, transparent),
    0 0 0 1px var(--line-soft) inset;
}

@media (prefers-reduced-motion: reduce) {
  .ygr-shot, .ygr-shot:hover, .ygr-shot:focus-visible { transform: none; transition: none; }
}

/* ---- Hero shot ------------------------------------------------------------ */

.ygr-hero {
  grid-column: full;
  margin: 0 0 clamp(1rem, 2.5vw, 1.75rem);
}

.ygr-hero figcaption,
.ygr-gallery figcaption {
  margin: .8rem 0 0;
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.5;
}

.ygr-gallery figcaption b {
  display: block;
  color: var(--text);
  font-weight: 600;
}

/* The opening problem and product rationale answer each other, so desktop
   readers can compare them directly before moving into the feature scenes. */
.ygr-intro {
  grid-column: full;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(2rem, 6vw, 5rem);
  margin: clamp(2.5rem, 6vw, 4rem) 0 0;
}
.ygr-intro__section { max-width: var(--measure); }
.ygr-intro__section h2 { grid-column: auto; margin-top: 0; }
.ygr-intro__section > :last-child { margin-bottom: 0; }

/* ---- Feature gallery ------------------------------------------------------ */

/* Selector carries the panel so it outranks .case-panel > ul, which would
   otherwise pull the gallery back into the text column. */
.case-panel > .ygr-gallery {
  grid-column: full;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
  gap: clamp(1.5rem, 3.5vw, 2.5rem);
  margin: clamp(1.5rem, 4vw, 2.5rem) 0 0;
  padding: 0;
  list-style: none;
}

.ygr-gallery figure { margin: 0; }

/* ---- Pull-out stat row ---------------------------------------------------- */

.ygr-stats {
  grid-column: text;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: clamp(1rem, 3vw, 2rem);
  margin: clamp(2rem, 5vw, 3rem) 0;
  padding: clamp(1.1rem, 3vw, 1.6rem) 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}

@media (min-width: 780px) {
  .ygr-stats {
    grid-column: full;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    text-align: center;
  }
}

.ygr-stat dt {
  margin: 0 0 .3rem;
  font-family: var(--display);
  font-size: clamp(1.7rem, 4vw, 2.4rem);
  line-height: 1;
  color: var(--accent);
}

.ygr-stat dd {
  margin: 0;
  color: var(--text-dim);
  font-size: .82rem;
  line-height: 1.45;
}

/* ---- Dataset disclosure --------------------------------------------------- */

.ygr-disclosure {
  grid-column: text;
  margin: clamp(2rem, 5vw, 3rem) 0 0;
  padding-left: 1rem;
  border-left: 2px solid var(--line-tick);
  color: var(--text-dim);
  font-size: .86rem;
  line-height: 1.6;
}

@media (max-width: 60rem) {
  .ygr-intro { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .ygr-intro__section + .ygr-intro__section h2 { margin-top: 3rem; }
  .ygr-scene {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(1.1rem, 4vw, 1.75rem);
  }
  /* On one column the flip is meaningless and would only reorder the stack. */
  .ygr-scene--flip { grid-template-columns: minmax(0, 1fr); }
  .ygr-scene--flip .ygr-scene__media { order: 0; }
  .ygr-scene__copy h2::before { left: -.75rem; }
}

/* ==========================================================================
   9. bevfantasy case study
   --------------------------------------------------------------------------
   Yggdrasil is screenshot-led with one wide capture per beat. bevfantasy is
   screenshot-led too, but the product is used on two surfaces that behave
   differently: a desktop where the dense tables and the equity curve are
   readable, and a phone on a couch, which is where a ballot actually gets
   filled out. So the unit here is a pair, the wide shot with the phone
   capture of the same screen overlapping its corner, rather than a single
   image. Everything else (the alternating sides, the lightbox, the scroll
   reveal) follows the same grammar as the Yggdrasil section, so the two case
   pages feel related without sharing a layout that only fits one of them.
   ========================================================================== */

/* ---- Header ---------------------------------------------------------------- */

/* The wordmark is the page heading and sits alone as the hero identity. */
.case.theme-bev .case-head__copy { width: 100%; }
.case.theme-bev .case-head__index { display: none; }

.bev-lockup {
  display: flex;
  justify-content: center;
}

.bev-title {
  margin: 0;
  line-height: 0;
}

.bev-mark {
  flex: 0 0 auto;
  width: clamp(15rem, 34vw, 24rem);
  height: auto;
  margin: 0;
  /* The source art is flat orange on transparent; the drop shadow gives it
     the same self-lit edge the screenshots have. */
  filter: drop-shadow(0 0 34px color-mix(in srgb, var(--accent) 42%, transparent));
}

.case.theme-bev .case-eyebrow {
  margin-top: clamp(1.25rem, 3vw, 2rem);
  color: var(--text);
  text-align: center;
}

/* The description used to sit as a narrow left column under a centered
   eyebrow; both now read as one centered block spanning the full header. */
.case.theme-bev .case-sub {
  max-width: none;
  text-align: center;
}

.case.theme-bev .case-facts {
  justify-content: center;
  text-align: center;
}

.case-fact__live[href] {
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--ygr) 45%, transparent);
  text-underline-offset: .18em;
  transition: color 220ms var(--ease), text-decoration-color 220ms var(--ease);
}

.case.theme-bev .case-fact__live[href] {
  color: var(--accent);
  text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent);
}

.case-fact__live[href]:hover { text-decoration-color: currentColor; }

/* ---- Intro spread --------------------------------------------------------- */

/* The problem and the constraint are a question and its answer, so they read
   better beside each other than stacked. Two columns of prose at the page's
   own measure, which is why this spans `full` rather than widening `text`. */
.bev-intro {
  grid-column: full;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(2rem, 6vw, 5rem);
  margin: clamp(2.5rem, 6vw, 4rem) 0 0;
}

.bev-intro__col { max-width: var(--measure); }

/* `.case-body h2` is a descendant selector, so it reaches into these columns
   and applies a `grid-column: text` that means nothing against .bev-intro's
   tracks, plus a 3.5rem top margin that would misalign the two headings. */
.bev-intro__col h2 {
  grid-column: auto;
  margin-top: 0;
}

.bev-intro__col > :last-child { margin-bottom: 0; }

/* ---- Heading ticks -------------------------------------------------------- */

/* The accent bar beside each heading alternates down the page. Same --bar
   indirection Yggdrasil uses for its per-team hues: unset falls back to the
   accent, so a heading added later still gets a mark rather than nothing. */
.case.theme-bev .bar-white { --bar: var(--text); }

.case.theme-bev .case-body h2::before,
.case.theme-bev .bev-scene__copy h2::before {
  background: var(--bar, var(--accent));
  box-shadow: 0 0 14px -3px var(--bar, var(--accent));
}

/* The act titles carry an eyebrow instead of a bar. */
.case.theme-bev .bev-act__title::before { content: none; }

@media (max-width: 60rem) {
  .bev-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  .bev-intro {
    grid-template-columns: minmax(0, 1fr);
    gap: 0;
  }
  /* Stacked, the second heading needs its own air back. */
  .bev-intro__col + .bev-intro__col h2 { margin-top: 3rem; }
}

@media (max-width: 48rem) {
  .bev-mark {
    width: clamp(10rem, 48vw, 13rem);
  }
}

/* ---- Act divider ---------------------------------------------------------- */

/* The page has two halves that make different arguments: what the site
   remembers, and what it was built to make people do. The divider says so out
   loud, because the reader otherwise has no reason to expect the second half
   to change register. */
.bev-act {
  grid-column: full;
  margin: clamp(4rem, 10vw, 7rem) 0 clamp(1.5rem, 4vw, 2.5rem);
  padding-top: clamp(1.25rem, 3vw, 2rem);
  border-top: 1px solid var(--line);
}

.bev-act__eyebrow {
  margin: 0 0 .5rem;
  color: var(--accent);
  font-size: .74rem;
  font-weight: 600;
  letter-spacing: .18em;
  text-transform: uppercase;
}

/* `.case-body h2` is a descendant selector, so it reaches in here and sets a
   `grid-column: text` that means nothing outside .case-body's own grid, plus a
   3.5rem top margin and an accent bar this heading does not want. All three are
   unset rather than worked around. */
.bev-act__title {
  grid-column: auto;
  margin: 0;
  font-size: clamp(1.6rem, 4vw, 2.6rem);
  letter-spacing: .08em;
  line-height: 1.05;
}

.bev-act__title::before { content: none; }

.bev-act__note {
  max-width: var(--measure);
  margin: .7rem 0 0;
  color: var(--text-dim);
  font-size: .92rem;
  line-height: 1.6;
}

/* The Evolution act reintroduces the wordmark from the page header, set
   beside the act's own title rather than above it. */
.bev-act--evolution {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: clamp(1.5rem, 4vw, 3rem);
}

.bev-act--evolution .bev-act__copy { flex: 1 1 auto; }

.bev-act__mark {
  flex: 0 0 auto;
  width: clamp(5rem, 11vw, 7.5rem);
  height: auto;
  filter: drop-shadow(0 0 24px color-mix(in srgb, var(--accent) 42%, transparent));
}

@media (max-width: 48rem) {
  .bev-act--evolution {
    flex-direction: column;
    align-items: flex-start;
  }
  .bev-act__mark { width: clamp(4rem, 20vw, 5.5rem); }
}

/* ---- Scene ---------------------------------------------------------------- */

.bev-scene {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 1.5fr) minmax(0, 23rem);
  gap: clamp(1.5rem, 4vw, 3.25rem);
  align-items: center;
  margin: clamp(3rem, 8vw, 5.5rem) 0;
}

/* Source order is always media then copy, so the one-column stack and the
   reading order never depend on which side won. Swapping the track widths
   along with the order is what stops `order` dropping the wide image into the
   narrow column. */
.bev-scene--flip {
  grid-template-columns: minmax(0, 23rem) minmax(0, 1.5fr);
}
.bev-scene--flip .bev-scene__media { order: 2; }

/* The earnings chart needs to read first; the two conversations are supporting
   evidence beneath it rather than competing with it in the prose column. */
.bev-scene--money { align-items: start; }
.bev-money-media { display: grid; gap: clamp(.9rem, 2vw, 1.4rem); }
.bev-money-media > .bev-shot img {
  aspect-ratio: 2.25 / 1;
  object-fit: cover;
  object-position: center top;
}
.bev-money-media__chats {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, .58fr);
  align-items: start;
  gap: clamp(.75rem, 1.7vw, 1.25rem);
}
.bev-money-media__chats figure { margin: 0; }
.bev-money-media__chats figure .bev-shot { width: 78%; margin-left: auto; }
.bev-money-media__chats figure img {
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: center 48%;
}
.bev-money-media__chats figcaption,
.bev-trade-phones figcaption {
  margin: .55rem 0 0;
  color: var(--text-dim);
  font-size: .76rem;
  line-height: 1.45;
}

/* Trades keep the filter and the proof visible together, so the archive reads
   as a searchable tool rather than a list of screens. */
.bev-scene--trades {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
  align-items: start;
}
.bev-scene--trades .bev-scene__media { order: 2; }
.bev-trade-phones {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-items: start;
  gap: clamp(.75rem, 1.7vw, 1.25rem);
}
.bev-trade-phones__phone,
.bev-trade-phones__chat { width: 100%; margin: 0; }
.bev-trade-phones .bev-shot { border-radius: clamp(10px, 1.6vw, 18px); }

/* Head-to-head needs the screenshots to interrupt the argument itself. Giving
   the scene one track lets the desktop result and phone capture use the page
   width without making the surrounding copy feel stretched. */
.bev-scene--ammunition {
  grid-template-columns: minmax(0, 1fr);
  align-items: start;
}
.bev-ammunition-shots {
  grid-template-columns: minmax(0, 1fr) minmax(12rem, 20rem);
  margin: clamp(1.25rem, 3vw, 2rem) 0;
}

/* The ballot and the feedback that changed it share the media column. The
   smaller receipt uses otherwise-empty space below the landscape capture. */
.bev-poll-media {
  display: grid;
  gap: clamp(1rem, 2.25vw, 1.5rem);
  align-content: start;
  align-self: start;
}

/* The two pieces of feedback that changed the poll sit side by side, each
   carrying one point, rather than one wide shot with the second point
   cropped out of view. */
.bev-poll-feedback {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(.75rem, 1.7vw, 1.1rem);
}
.bev-poll-feedback .bev-note-shot { margin: 0; }

/* Manufacturing an event carries far more prose than the other scenes, so the
   usual 23rem copy column runs much taller than the media beside it. Giving
   copy more width shortens that column and closes most of the gap; align-items
   start keeps the media flush with the heading instead of centered in the
   leftover space. */
.bev-scene--poll {
  grid-template-columns: minmax(0, 28rem) minmax(0, 1.2fr);
  align-items: start;
}

/* Crop to the useful keeper controls rather than showing the entire desktop
   page at a reduced scale. */
.bev-scene--keepers .bev-scene__media > .bev-shot img {
  aspect-ratio: 4 / 3;
  object-fit: cover;
  object-position: center 40%;
}

.bev-scene__copy[data-reveal] { transition-delay: 110ms; }
.bev-scene__copy > :first-child { margin-top: 0; }
.bev-scene__copy > :last-child  { margin-bottom: 0; }
.bev-scene__copy p { margin: 0 0 1.1rem; }

.bev-scene__copy h2 {
  position: relative;
  margin: 0 0 1rem;
  font-family: var(--display);
  font-size: clamp(1.5rem, 2.6vw, 1.95rem);
  font-weight: 400;
  letter-spacing: .06em;
  line-height: 1.05;
  text-transform: uppercase;
}

.bev-scene__copy h2::before {
  content: '';
  position: absolute;
  left: -1.1rem;
  top: .1em;
  bottom: .1em;
  width: 3px;
  background: var(--accent);
  border-radius: 2px;
}

/* The one line of hard evidence under a scene: a count, a record, a filter
   result. Small, and deliberately not a pull quote. */
.bev-scene__proof {
  margin: 1.4rem 0 0;
  padding-left: .9rem;
  border-left: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.55;
}

.bev-scene__proof b { color: var(--text); font-weight: 600; }

/* The one ticker symbol that's a signature rather than a data point. */
.bev-will { color: #b18cff; font-weight: 600; }

.bev-scene__dek {
  margin: -.15rem 0 1.25rem;
  color: var(--text-dim);
  font-size: .9rem;
  line-height: 1.55;
}

.bev-feature-list {
  display: grid;
  gap: .75rem;
  margin: 0 0 1.1rem;
  padding-left: 1.15rem;
  color: var(--text-dim);
}

.bev-feature-list li { padding-left: .2rem; }

/* ---- Shot ----------------------------------------------------------------- */

/* A screenshot of a dark, self-lit interface on a near-black page has no edge
   of its own; the border and accent bloom give it one without a bright frame. */
.bev-shot {
  display: block;
  width: 100%;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-lo);
  overflow: hidden;
  box-shadow:
    0 18px 50px -22px rgba(0, 0, 0, .9),
    0 0 0 1px var(--line-soft) inset;
  transition:
    transform  420ms var(--ease-out),
    box-shadow 420ms var(--ease-out),
    border-color 420ms var(--ease);
}

button.bev-shot { cursor: zoom-in; }

.bev-shot img { display: block; width: 100%; height: auto; }

.bev-shot:hover,
.bev-shot:focus-visible {
  transform: translateY(-5px);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  box-shadow:
    0 26px 64px -24px rgba(0, 0, 0, .95),
    0 0 46px -12px color-mix(in srgb, var(--accent) 38%, transparent),
    0 0 0 1px var(--line-soft) inset;
}

/* ---- Device pair ---------------------------------------------------------- */

/* First attempt overlapped the phone onto the wide shot's corner, which looked
   better and read worse: the corner it covered held real content on half the
   screenshots. The pair is a grid instead, so the phone never occludes anything
   and the arrangement holds no matter what the two captures contain. */
.bev-pair {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 21%;
  align-items: end;
  gap: clamp(.6rem, 1.5vw, 1.1rem);
}

/* The phone gets a heavier radius and a visible bezel, because at 21% width a
   bare screenshot reads as a thin sliver rather than a device. */
/* A chat screenshot is carrying TEXT, and at the narrow slot a product
   screenshot is happy with, the messages are illegible until you open the
   lightbox. These get a wider slot. The copy beside them
   quotes the line that matters, and the image is there as evidence that the
   quote is real. */
.bev-pair--chat { grid-template-columns: minmax(0, 1fr) 38%; }

.bev-pair__phone .bev-shot {
  border-radius: clamp(10px, 1.6vw, 18px);
  border-width: 2px;
  border-color: color-mix(in srgb, var(--text) 14%, transparent);
  box-shadow:
    0 22px 44px -14px rgba(0, 0, 0, .95),
    0 0 0 1px rgba(0, 0, 0, .6);
}

/* ---- Hero ----------------------------------------------------------------- */

.bev-hero {
  grid-column: full;
  margin: 0 0 clamp(1rem, 2.5vw, 1.75rem);
}

.bev-hero figcaption,
.bev-gallery figcaption {
  margin: .8rem 0 0;
  color: var(--text-dim);
  font-size: .84rem;
  line-height: 1.5;
}

.bev-gallery figcaption b { display: block; color: var(--text); font-weight: 600; }

/* ---- Closing gallery ------------------------------------------------------ */

/* The selector carries .case-body so it outranks `.case-body > *`, which would
   otherwise pull the list back into the text column. */
.case-body > .bev-gallery {
  grid-column: full;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
  gap: clamp(1.25rem, 3vw, 2rem);
  margin: clamp(1.5rem, 4vw, 2.5rem) 0 0;
  padding: 0;
  list-style: none;
}

.bev-gallery figure { margin: 0; }

/* ---- Stat row ------------------------------------------------------------- */

/* Spans `full` to line up with the intro spread directly above it. In the
   narrow text column the four figures wrapped 3 + 1, which read as a mistake
   sitting under two clean columns. */
.bev-stats {
  grid-column: full;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(1rem, 3vw, 2rem);
  margin: clamp(2rem, 5vw, 3rem) 0;
  padding: clamp(1.1rem, 3vw, 1.6rem) 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}

.bev-stat dt {
  margin: 0 0 .3rem;
  font-family: var(--display);
  font-size: clamp(1.6rem, 3.6vw, 2.2rem);
  line-height: 1;
  color: var(--accent);
}

.bev-stat dd {
  margin: 0;
  color: var(--text-dim);
  font-size: .82rem;
  line-height: 1.45;
}

/* ---- Paired figures in the text column ------------------------------------ */

/* Two phone-shaped screenshots side by side, sized to the text column rather
   than the full width — they are an aside to the prose, not a scene of their
   own, and at scene width two portrait shots would tower over the section
   that introduces them. */
.case-body > .bev-duo {
  grid-column: text;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(.75rem, 2vw, 1.1rem);
  margin: clamp(1.25rem, 3vw, 1.75rem) 0 clamp(1.5rem, 4vw, 2rem);
  padding: 0;
  list-style: none;
}

.bev-duo li { margin: 0; }

/* The mark's story is prose-first, so it gets the wide track; the brief and
   the first draft sit beside it as reference rather than leading the section. */
.bev-mark-row {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 28rem) minmax(0, 1.2fr);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: start;
  margin: clamp(2.5rem, 6vw, 4rem) 0 0;
}

.bev-mark-row__copy { max-width: var(--measure); }
.bev-mark-row__copy h2 { margin-top: 0; }
.bev-mark-row__copy > :last-child { margin-bottom: 0; }

.bev-mark-media {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-content: start;
  gap: clamp(.75rem, 1.6vw, 1.1rem);
  margin: 0;
  padding: 0;
  list-style: none;
}
.bev-mark-media li { margin: 0; }

/* Inline link inside body prose. `.case-body a` has no treatment of its own,
   so without this the one link in the running text is indistinguishable from
   the text around it. */
.bev-link {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent);
  text-underline-offset: .18em;
  transition: text-decoration-color 250ms var(--ease), color 250ms var(--ease);
}

.bev-link:hover { color: var(--accent); text-decoration-color: var(--accent); }

/* ---- Inline note figure --------------------------------------------------- */

/* A screenshot dropped into a scene's prose column rather than its media
   column, for evidence that belongs to one paragraph rather than to the whole
   beat. Sized by the column, so it stays legible without competing with the
   product shot beside it. */
.bev-note-shot {
  margin: 0 0 1.1rem;
}

.bev-note-shot .bev-shot { border-radius: 10px; }

/* Small, zoomable social proof. The thumbnail stays compact while the
   lightbox retains the complete privacy-edited portrait screenshot. */
.bev-receipt {
  width: min(78%, 17rem);
  margin: 1.35rem 0 1.4rem auto;
}

.bev-receipt--left { margin-right: auto; margin-left: 0; }

.bev-receipt .bev-shot { border-radius: 12px; }

.bev-receipt img {
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: center 48%;
}

.bev-receipt figcaption,
.bev-evolution figcaption {
  margin: .65rem 0 0;
  color: var(--text-dim);
  font-size: .78rem;
  line-height: 1.45;
}

.bev-evolution {
  grid-column: full;
  margin: clamp(1.5rem, 4vw, 2.5rem) 0 clamp(2rem, 5vw, 3rem);
}

.bev-evolution .bev-shot { border-radius: 12px; }

/* The old site is evidence, not a hero shot — shown at a size that matches
   the weight it carries in the story now that the power rankings image
   introduces this beat instead. */
.bev-evolution--tight {
  max-width: 34rem;
  margin-left: auto;
  margin-right: auto;
}

/* The power rankings spreadsheet leads "How far it's come", with the heading
   and its paragraph reading beside it rather than underneath. */
.bev-history-row {
  grid-column: full;
  display: grid;
  grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: start;
  margin: clamp(2.5rem, 6vw, 4rem) 0 0;
}

/* The old site screenshot now stacks under the power rankings shot rather
   than running full-width later in the page, so this column holds two
   figures instead of one. */
.bev-history-row__media {
  display: grid;
  gap: clamp(1.25rem, 3vw, 2rem);
  margin: 0;
}
.bev-history-row__media figure { margin: 0; }
.bev-history-row__media .bev-evolution {
  grid-column: auto;
  margin: 0;
}
.bev-history-row__media .bev-evolution--tight {
  max-width: none;
}
.bev-history-row__media .bev-shot { border-radius: 12px; }
.bev-history-row__media figcaption {
  margin: .65rem 0 0;
  color: var(--text-dim);
  font-size: .78rem;
  line-height: 1.45;
}

.bev-history-row__copy { max-width: var(--measure); }
.bev-history-row__copy h2 { margin-top: 0; }
.bev-history-row__copy > :last-child { margin-bottom: 0; }

/* Closing section reads as a centered statement, the same grammar as the
   "finished identity" close on the Into the Storm vision tab. */
.bev-next {
  grid-column: text;
  max-width: var(--measure);
  margin: clamp(4rem, 10vw, 7rem) auto 0;
  padding-top: clamp(1.5rem, 4vw, 2.5rem);
  border-top: 1px solid var(--line);
  text-align: center;
}

.bev-next h2 { margin-top: 0; }

.bev-next h2::before {
  left: 50%;
  bottom: -.6rem;
  top: auto;
  height: 2px;
  width: 2.75rem;
  transform: translateX(-50%);
}

.bev-next > :last-child { margin-bottom: 0; }

/* ---- Credits -------------------------------------------------------------- */

.bev-credit {
  grid-column: text;
  margin: clamp(2rem, 5vw, 3rem) 0 0;
  padding-left: 1rem;
  border-left: 2px solid var(--line-tick);
  color: var(--text-dim);
  font-size: .86rem;
  line-height: 1.6;
}

.bev-credit a {
  color: var(--text);
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent);
  text-underline-offset: .18em;
  transition: text-decoration-color 250ms var(--ease), color 250ms var(--ease);
}

.bev-credit a:hover { color: var(--accent); text-decoration-color: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  .bev-shot, .bev-shot:hover, .bev-shot:focus-visible { transform: none; transition: none; }
}

@media (max-width: 60rem) {
  .bev-scene,
  .bev-scene--flip { grid-template-columns: minmax(0, 1fr); }
  .bev-scene {
    gap: clamp(1.1rem, 4vw, 1.75rem);
    margin: clamp(2.5rem, 8vw, 4rem) 0;
  }
  .bev-scene--flip .bev-scene__media { order: 0; }
  .bev-scene--trades .bev-scene__media { order: 0; }
  .bev-scene__copy h2::before { left: -.75rem; }

  /* On one column a 21% phone would be a few millimetres wide, so the pair
     stacks and the phone takes a share you can actually read. */
  .bev-pair { grid-template-columns: minmax(0, 1fr); }
  .bev-pair__phone { width: 46%; justify-self: end; }
  .bev-money-media__chats { grid-template-columns: minmax(0, 1fr) minmax(0, .65fr); }
  .bev-trade-phones { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .bev-mark-row { grid-template-columns: minmax(0, 1fr); gap: 1.25rem; }
  .bev-history-row { grid-template-columns: minmax(0, 1fr); }
}
