/* =========================================================================
   Ludic Media Studio — components
   One rule per block in _build/blocks.py, matching the archived layout.
   Same breakpoint ladder as foundation.css: 36 / 48 / 64 / 80rem.
   Same naming: block__element--modifier.
   ========================================================================= */

/* ---------- home hero ---------------------------------------------------
   MEASURED: 592px tall, a full-bleed background with a 554x368 yellow card
   floating over it, card padding 48px 40px, and the Media Studio wordmark
   192px wide inside it.

   The background is a Vimeo player, and Vimeo is a third party, so the iframe
   is NOT in the HTML — js/site.js inserts it only once the visitor has
   accepted marketing cookies. Underneath it, and always present, is the
   original's own lazy-load still. That ordering is the point: before any
   consent decision the hero is the still image and looks finished, rather
   than being a grey box with "accept cookies to see this".
   ----------------------------------------------------------------------- */
.hero--home { position: relative; overflow: hidden; background: var(--black); min-height: 26rem; }
@media (min-width: 64rem) { .hero--home { min-height: 37rem; } }

.hero__media,
.hero__media-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border: 0;
}
.hero__media-poster { z-index: 0; }
.hero__media { z-index: 1; }

/* A 16:9 player cropped to a 2.4:1 hero has to be scaled up and centred or
   it letterboxes; this is the standard cover-an-iframe trick, and it is why
   the iframe has no controls — nothing interactive is reachable off-screen. */
.hero__media--frame {
  top: 50%; left: 50%;
  width: 100vw;
  height: 56.25vw;          /* 16:9 */
  min-height: 100%;
  min-width: 177.78vh;      /* 16:9 the other way */
  transform: translate(-50%, -50%);
  pointer-events: none;
}

.hero__inner {
  position: relative;
  z-index: 2;
  display: grid;
  padding-block: var(--space-lg);
}
@media (min-width: 64rem) {
  /* align-items: start stops the card stretching to the row height and
     leaving dead space under the button. */
  .hero__inner { grid-template-columns: minmax(0, 34.625rem) 1fr; padding-block: var(--space-2xl); align-items: start; }
}

/* MEASURED 554x368 with 48px/40px padding. Yellow, and therefore black copy:
   white here is 1.58:1. */
.hero__card {
  background: var(--surface-accent);
  color: var(--black);
  padding: var(--space-md) var(--space-sm);
}
@media (min-width: 48rem) { .hero__card { padding: var(--space-lg) var(--container-pad); } }

.hero__lockup { width: min(12rem, 100%); height: auto; margin-bottom: var(--space-sm); }

/* MEASURED 20px — the archived hero card's copy is .text-size-medium, not a
   heading at all: that page ships NO h1 anywhere, which is why this one is
   an h1. Level and size are separate things, and the size stays the
   measured one.

   The clamp's floor was 1.5rem on the first pass, which made this LARGER on
   a phone (24px) than on a desktop (20px), because 2.6vw at 390px is 10px
   and the floor won. A clamp whose minimum exceeds its maximum at small
   widths is a size inversion, and it is invisible unless you look at the
   mobile screenshot beside the desktop one. */
.hero__title { font-size: clamp(1.125rem, 1.6vw, var(--text-md)); font-weight: 400; line-height: var(--leading-mid); }
.hero__card .prose__p { margin-top: var(--space-xs); max-width: none; }
.hero__card .prose__p strong { font-weight: 700; }

/* ---------- interior page hero ------------------------------------------
   The about page's hero: same yellow card, over a still, with the h1 in it.
   ----------------------------------------------------------------------- */
.hero--page { position: relative; overflow: hidden; background: var(--black); }
/* MEASURED 32px on the archived About page, whose hero card DOES carry the
   page's h1. */
.hero--page .hero__title { font-size: clamp(1.375rem, 3.2vw, var(--text-xl)); }

/* The plain banner hero — Contact, the legal pages, 404: a yellow band with
   the page title in it and nothing else. MEASURED on the contact page. */
.hero--band {
  background: var(--surface-accent);
  color: var(--black);
  padding-block: var(--space-md);
}
@media (min-width: 64rem) { .hero--band { padding-block: var(--space-lg); } }
.hero--band .prose__p { margin-top: var(--space-2xs); max-width: 60ch; }

/* ---------- service hero: gallery + rotated title -----------------------
   MEASURED: a 1056x508 stage, a row of 290x163 thumbnails 64px beneath it,
   and the page title set at 64px and rotated 90 degrees down the right edge
   inside a 392x398 box.

   The original is a Webflow slider: a JS carousel that, with scripting off,
   shows all eleven images stacked. This is a scroll-snap strip instead —
   swipe and trackpad scroll work with no JavaScript at all, the thumbnails
   are ANCHORS to each slide so they work too, and the only thing JS adds is
   the prev/next pair and keeping the current thumbnail marked. That is why
   .gallery__controls is hidden unless html[data-js] is set: a button that
   cannot do anything should not be on the page.
   ----------------------------------------------------------------------- */
.gallery { padding-block: var(--space-md) var(--space-lg); }
@media (min-width: 64rem) { .gallery { padding-block: var(--space-lg) var(--space-xl); } }

/* Below the desktop breakpoint the title comes FIRST, above the gallery. In
   the DOM it sits between the stage and the thumbnails, because that is where
   the rotated desktop layout needs it — but reading a page whose h1 arrives
   after eleven images is odd, and the archived mobile layout puts the title
   at the top too. `order` moves the box without moving the element, so the
   heading order a screen reader announces is unchanged. */
.gallery__inner { position: relative; display: flex; flex-direction: column; }
.gallery__title { order: -1; }
@media (min-width: 64rem) {
  .gallery__title { order: 0; }
  /* Stage left, rotated title right — the title column is sized from the
     measured 392px box, expressed as a share of the 1056px content measure. */
  .gallery__inner { display: grid; grid-template-columns: minmax(0, 1fr) 6rem; column-gap: var(--space-md); }
  .gallery__stage { grid-column: 1; }
  .gallery__title { grid-column: 2; grid-row: 1; }
  /* The thumbnail row runs the FULL content measure, under both the stage and
     the title column — it is the page's bottom edge, so it should line up with
     everything else on the page rather than with the stage above it. Four
     across the 66rem measure: (66 - 3 x 0.5) / 4 = 16.1rem. */
  .gallery__thumbs { grid-column: 1 / -1; }
  .gallery__thumbs > li { flex: 0 0 16.1rem; }
}

.gallery__stage { position: relative; }

/* min-width: 0 and an explicit overflow-y are both load-bearing.
   Without min-width, a scrolling strip inside a grid track takes its
   automatic minimum size from its CONTENT, so the track grows to fit all
   eleven slides and the whole document ends up 2562px wide with a horizontal
   scrollbar on a 1440 screen. And with overflow-x: auto beside an implicit
   overflow-y: visible, the two axes disagree and a browser is entitled to
   resolve the visible one to auto — so the strip grows a vertical scrollbar
   nobody asked for. Both were real on the Design and Visual Scribing pages. */
.gallery__slides {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  gap: 0;
  margin: 0;
  padding: 0;
  min-width: 0;
  list-style: none;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  contain: paint;
}
.gallery__slides::-webkit-scrollbar { display: none; }
.gallery__slide { scroll-snap-align: center; }

/* The single-image hero (Comms Campaigns). One image at its own ratio, so
   nothing is cropped: --stage-h is derived from that ratio in
   blocks.py::image_hero rather than being the fixed 508px box the carousel
   heroes use. There is no thumbnail strip and no prev/next pair to place. */
.gallery--single .gallery__single {
  width: 100%;
  height: var(--stage-h);
  object-fit: cover;      /* the box IS the image's ratio, so nothing is lost */
  object-position: 50% 50%;
  background: var(--cream);
}

/* A FIXED STAGE, and the artwork cropped to fill it.

   Taken from the archive rather than derived: `.slider-service-hero` is
   `width: 88%; height: 508px` and `.image-service-slide` is
   `object-fit: cover; object-position: 50% 5%`. Measured off the live page's
   own screenshot, the stage is 507px tall — so 508 it is.

   This replaces a stage whose ratio came from the median ratio of each
   gallery's own images, which cropped nothing and matted nothing. The client
   asked for the fixed box, and the trade is real: a 1.48:1 scribing board in
   this 1.83:1 box loses about a fifth of its height. `object-position: 50% 5%`
   is the archive's answer and the right one — these boards carry their title
   along the top, so the crop comes off the bottom.

   Cream stays behind the image for the moment before it decodes. */
.gallery__slide img {
  width: 100%;
  height: var(--stage-h);
  object-fit: cover;
  object-position: 50% 5%;
  background: var(--cream);
}

/* The prev/next pair. Every number here is the archive's, cross-checked
   against a screenshot of the live page:

     .slider-arrow-hero      152x85 each, background #ffd728
     .slider-arrow-hero.right   right: -120px      (past the stage's edge)
     .slider-arrow-hero.left    right:   32px      -> the two abut exactly
     .slider-arrow-hero.left    border-right: 1px solid rgba(0,0,0,.08)

   Measured on the live page: block 303x84, its right edge 120px past the
   stage's right edge, its left edge 183px inside it, and its top 42px ABOVE
   the stage's bottom — half of 85. So the pair is centred on the stage's
   bottom edge, not hung below it.

   WHY IT REACHES PAST THE STAGE. The archive's stage is 88% of the hero and
   the arrows' right edge lands within a couple of pixels of the remaining
   12% — so the block finishes flush with the hero's full width, level with the
   thumbnail strip below and the title column above. This build's stage is
   58rem of a 66rem measure (87.9%), so 7.5rem of overhang puts it in the same
   place. It stays inside the content box, so it cannot widen the document. */
.gallery__controls { display: none; }
[data-js] .gallery__controls {
  display: flex;
  position: absolute;
  right: 0;
  bottom: 0;
  z-index: 2;
  background: var(--yellow);
}
/* The overhang only at desktop: below 64rem there is no title column for it to
   sit against and the gutter is narrower, so it stays inside the corner. Set
   here rather than as a max-width query — four breakpoints, all min-width,
   and checks.py fails on a fifth. */
@media (min-width: 64rem) {
  [data-js] .gallery__controls {
    right: -7.5rem;          /* 120px past the stage — flush with the hero */
    bottom: auto;
    top: 100%;
    transform: translateY(-50%);   /* centred on the stage's bottom edge */
  }
}
.gallery__btn {
  display: grid;
  place-items: center;
  width: 3.25rem; height: 2.75rem;
  background: none;
  border: 0;
  color: var(--black);
  cursor: pointer;
  transition: background-color var(--duration) var(--ease);
}
@media (min-width: 48rem) { .gallery__btn { width: 4.75rem; height: 3.3125rem; } }
/* 152 x 85 at desktop — the archive's `.slider-arrow-hero`. Set HERE, after
   the size ladder above, rather than in the earlier .gallery__controls media
   block: a media query adds no specificity, so a later plain rule wins and the
   buttons came out 76x53. Same-specificity rules are resolved by order, and
   the order is the file. */
@media (min-width: 64rem) {
  .gallery__btn { width: 9.5rem; height: 5.3125rem; }
  .gallery__btn + .gallery__btn { border-left: 1px solid rgb(0 0 0 / 0.08); }
}
.gallery__btn:hover { background: var(--yellow-deep); }
.gallery__btn[disabled] { opacity: 0.4; cursor: default; }
.gallery__btn[disabled]:hover { background: none; }
.gallery__btn svg { width: 1.75rem; height: 1.75rem; }

/* MEASURED: four across at 290px with 10px margins, 64px below the stage —
   and the row SCROLLS. There are eleven images on the Design page and nine on
   Visual Scribing; laying them out as a grid stacked them into three rows and
   turned the thumbnail strip into a second, larger gallery above the real
   one. A scrolling row of four is what the original does and what the
   measurement says.

   grid-auto-flow: column with a fixed track size, rather than flex, so the
   scroll-snap positions land on whole thumbnails. */
/* Three things here are the result of measurement, not preference.

   FLEX, NOT GRID. `grid-auto-columns: calc(25% - x)` on a scrolling grid
   resolves its percentage against the whole SCROLL width rather than the
   visible width, so eleven thumbnails came out at a ninth of the row each
   instead of a quarter.

   FIXED TRACK WIDTHS, NOT PERCENTAGES. A percentage flex-basis inside a
   scroll container has the same feedback problem in a milder form: with
   `calc(25% - x)` the Design page's document measured 2562px wide on a 1440
   screen, and swapping the basis for a fixed length brought that to 2092 —
   better, and still wrong. 15.5rem is the archived 290px thumbnail, and four
   of them plus three 1.25rem gaps come to 65.75rem inside the 66rem content
   measure.

   `contain: paint` IS LOAD-BEARING. Even with the box correctly 928px wide
   and scrolling internally, Chromium propagates a scroll container's
   scrollable overflow into its ancestors' scrollWidth — so the page grew a
   real horizontal scrollbar (max scrollX 1122) while every element was
   exactly where it should be. `contain: paint` is what actually stops it;
   `max-width: 100%`, `overflow: auto hidden` and `min-width: 0` all measured
   as no change. browser_checks.py asserts no page scrolls sideways at any of
   the three widths, because nothing about this is visible in a screenshot. */
.gallery__thumbs {
  display: flex;
  gap: var(--space-2xs);
  /* 4rem, the archive's own `.thumbnail-carousel { margin-top: 4rem }`. Its
     thumbnails also carry `margin: 10px`, so the measured gap on the live page
     is 74px from the stage's bottom edge to the top of a thumbnail; the padding
     below does the same job here.

     THE PADDING IS WHAT THE CURRENT THUMBNAIL'S RING SITS IN. That ring is an
     outline drawn OUTSIDE the image (see .gallery__thumb img), and this element
     is a scroll container with `overflow-y: hidden` and `contain: paint` — both
     of which clip to the padding box. With no padding the ring was sliced off
     along the top and down the left-hand side of the first thumbnail.
     --thumb-ring is the room it needs: the rule's own width plus its offset.

     The left padding is cancelled by an equal negative margin, so the first
     thumbnail still lines up with the stage above it rather than being nudged
     5px to the right. */
  --thumb-ring: calc(var(--frame-w) + 2px);
  margin: 4rem 0 0 calc(-1 * var(--thumb-ring));
  padding: var(--thumb-ring) var(--thumb-ring) var(--space-2xs) var(--thumb-ring);
  min-width: 0;
  list-style: none;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  /* Inset the SNAPPORT by the same amount as the padding. Without this the
     browser snaps the first thumbnail's start edge flush with the scrollport
     start — scrolling the strip 5px to the right and putting the ring straight
     back under the clip it was given padding to escape. The padding provides
     the room; scroll-padding stops the snap from taking it away. */
  scroll-padding-inline: var(--thumb-ring);
  scrollbar-width: thin;
  contain: paint;
}
.gallery__thumbs > li {
  flex: 0 0 10.5rem;
  scroll-snap-align: start;
}
@media (min-width: 48rem) {
  .gallery__thumbs > li { flex-basis: 13rem; }
}
@media (min-width: 64rem) {
  .gallery__thumbs { gap: 1.25rem; }
  .gallery__thumbs > li { flex-basis: 15.5rem; }
}

.gallery__thumb { display: block; text-decoration: none; }
.gallery__thumb img {
  width: 100%;
  aspect-ratio: 290 / 163;
  object-fit: cover;
  background: var(--cream);
  outline: var(--frame-w) solid transparent;
  outline-offset: 2px;
  transition: outline-color var(--duration) var(--ease), opacity var(--duration) var(--ease);
}
.gallery__thumb:hover img { opacity: 0.85; }

/* The current thumbnail, in the brand yellow at the client's direction.
   OUTSIDE the image, not inset. Several of these thumbnails are themselves
   mostly yellow — the "Passion" scribe is a yellow field — and an inset yellow
   rule on yellow artwork is invisible, which is a poor way to say "you are
   here". Sat outside, it reads against the white page instead.
   Yellow on white is only 1.7:1, so this is not carrying the meaning on its
   own: the element also has aria-current, and the difference between the
   states is a rule being present or absent rather than a change of hue. */
.gallery__thumb[aria-current="true"] img { outline-color: var(--yellow); opacity: 1; }

/* The rotated title. `writing-mode` rather than a transform, so the box the
   text occupies is the real one and the grid column does not have to be
   guessed; and it stays selectable, findable by Ctrl-F and readable by a
   screen reader in the right order.

   ONE h1 per page. The original ships two — a horizontal one for mobile and
   a rotated one for desktop, with the other display:none — which is two h1s
   in the document whatever the width. This is the same element in both
   places, turned by a media query. */
.gallery__title {
  font-size: clamp(2rem, 5vw, var(--text-2xl));
  font-weight: 400;
  line-height: var(--leading-tight);
  margin-bottom: var(--space-sm);
}
@media (min-width: 64rem) {
  .gallery__title {
    writing-mode: vertical-rl;
    margin-bottom: 0;
    justify-self: end;

    /* SIZED FROM ITS OWN LENGTH.
       Turned on its side, the title is one column whose length is the string's
       set width, and the space it has is the stage's height. At a flat 64px
       "Design Studio" fits and "Strategic Visualization" does not — it wrapped
       into a second column and overran the stage. One rule, two inputs:

         --stage-h   the stage's height. A plain length for the carousel
                     heroes (foundation.css); derived from the image's own
                     ratio for the single-image one (blocks.py::image_hero).
         --title-em  the title's set width in em, MEASURED FROM THE FONT —
                     see chrome.TITLE_EM. This was a character count times a
                     constant, and a constant cannot work: the same face runs
                     0.447 em/char for "Strategic Visualization" and 0.602 for
                     "Comms Campaigns". The middle value made the narrow ones
                     needlessly small and let Comms Campaigns overrun its
                     stage by 55px.

       Capped at --text-3xl, the archive's measured 64px, so a short title does
       not grow past it. max-height is a second belt: whatever the arithmetic
       says, the title can never make the grid row taller than the stage.
       browser_checks.py measures the rendered title against the stage on every
       hero — the comment here used to claim that check existed before it did. */
    max-height: var(--stage-h);
    font-size: max(1.75rem,
                   min(var(--text-3xl), calc(var(--stage-h) / var(--title-em, 10))));
  }
}

/* A service hero that is a video rather than a gallery (Film & Animation,
   Explainer Videos). Consent-gated like the home hero, with a poster and a
   play affordance underneath. */
.video-hero { position: relative; background: var(--cream); }
.video-hero__frame { position: relative; aspect-ratio: 16 / 9; background: var(--black); }
.video-hero__frame iframe,
.video-hero__frame img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; }

.video-hero__gate {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--space-xs);
  padding: var(--space-sm);
  text-align: center;
  background: rgb(0 0 0 / 0.45);
  color: var(--white);
}
.video-hero__gate p { max-width: 30ch; font-size: var(--text-sm); line-height: 1.6; }

/* ---------- statement --------------------------------------------------- */
.section--statement .statement { max-width: none; }

/* ---------- client logo grid -------------------------------------------
   MEASURED: four columns of 228px with a 16px gap, 960px overall, and the
   logos at wildly different optical sizes because the original places the
   files as-is — every one of them a differently-padded box.

   Each logo here is cropped to its own ink by
   _build/migration/harvest_assets.py, then CONTAINed in one fixed box, so a
   wide mark and a tall mark end up carrying similar visual weight. That is a
   deliberate difference from the original and the only one in this block.
   ----------------------------------------------------------------------- */
.client-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-md) var(--space-xs);
  align-items: center;
  margin: 0;
  padding: 0;
  list-style: none;
}
@media (min-width: 36rem) { .client-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 48rem) { .client-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* Defaults for the two properties blocks.py sets per logo. They are declared
   here rather than left to a var() fallback so the CSS check can prove every
   custom property this stylesheet reads is defined somewhere — a typo'd
   var() is otherwise completely silent. */
.client-grid > li { --logo-w: 8rem; --logo-h: 2.83rem; }

/* --logo-w / --logo-h come from blocks.py::client_grid, which sizes every
   mark to the same AREA so a 5.3:1 wordmark and a 0.73:1 stacked lockup carry
   the same weight. The min() keeps a wide mark inside a narrow column on a
   phone; the aspect-ratio makes the height follow whatever the width ends up
   being, so the proportion survives the clamp. */
.client-grid__logo {
  width: min(var(--logo-w, 8rem), 100%);
  height: auto;
  aspect-ratio: var(--logo-w) / var(--logo-h);
  margin-inline: auto;
  object-fit: contain;
}

/* ---------- feature split ----------------------------------------------
   MEASURED: two 504px columns with the image 504x369.6, heading 24px/700,
   body 14.4px. `flip` puts the image on the right, which is the pattern the
   home page alternates down its four service rows.
   ----------------------------------------------------------------------- */
.feature { display: grid; gap: var(--space-md); align-items: center; }
@media (min-width: 64rem) {
  .feature { grid-template-columns: 1fr 1fr; gap: var(--space-lg); }
  .feature--flip .feature__media { order: 2; }
}

.feature__media { margin: 0; }
.feature__image { width: 100%; }

.feature__title { font-size: var(--text-lg); font-weight: 700; line-height: var(--leading-snug); margin-bottom: var(--space-xs); }
.feature__body { font-size: var(--text-sm); line-height: var(--leading-body); max-width: 52ch; }
.feature__body strong { font-weight: 700; }

/* ---------- text columns -----------------------------------------------
   The "we tell stories" / "what is visual scribing?" pattern: an optional
   centred heading, then one or two columns of 20px body copy.
   ----------------------------------------------------------------------- */
.text-columns { display: grid; gap: var(--space-md); }
@media (min-width: 64rem) { .text-columns--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-lg); } }
.text-columns p { font-size: var(--text-md); line-height: var(--leading-mid); }
.text-columns p + p { margin-top: var(--space-xs); }
.text-columns strong { font-weight: 700; }

/* ---------- plain text-item grid ---------------------------------------
   "Design services", "Why visual scribing works", "Games services": a
   subhead and a paragraph, no card, no border. MEASURED subhead 20px/500,
   body 16px.
   ----------------------------------------------------------------------- */
.text-items { display: grid; gap: var(--space-md); margin: 0; padding: 0; list-style: none; }
@media (min-width: 36rem) { .text-items--2, .text-items--3, .text-items--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) {
  .text-items--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .text-items--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .text-items { gap: var(--space-md) var(--space-lg); }
}

.text-item__title { font-size: var(--text-md); font-weight: 500; line-height: var(--leading-snug); margin-bottom: var(--space-2xs); }
.text-item__body { font-size: var(--text-base); line-height: var(--leading-body); }

/* NOTE: there is no `picture` selector anywhere in this stylesheet, and that
   is deliberate. Every raster in assets/ is WebP, so blocks.picture() emits a
   plain <img> with a srcset rather than a <picture> wrapper — see the comment
   in that function. If a non-WebP source is ever added, the wrapper comes
   back and these rules need `, .x picture` again.
   ----------------------------------------------------------------------- */

/* ---------- media cards ------------------------------------------------
   "Solutions and formats", "Why choose Ludic Media Studio?": a 16:9 image
   with a yellow border, a subhead and a paragraph, three across.
   MEASURED image 378.7x213.1 with a 16px gap under it.
   ----------------------------------------------------------------------- */
/* grid-auto-rows: 1fr equalises EVERY row in the section, not just the cards
   within a row. Without it a section's rows come out 292 / 336 / 360px tall
   because one card carries a "Learn more" link and two have a longer body —
   internally consistent, but three different heights down the page.

   Nothing is truncated to achieve it: the slack lands below the body copy,
   which is white on a white card and invisible. That is the whole reason this
   works here and clamping the body would not: these are hand-written service
   descriptions, and cutting a line off one to make the grid tidy is a worse
   trade than the case-study titles, which are CMS titles with a full copy on
   their own page. */
.media-cards {
  display: grid;
  grid-auto-rows: 1fr;
  gap: var(--space-md);
  margin: 0;
  padding: 0;
  list-style: none;
}
@media (min-width: 36rem) { .media-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) {
  .media-cards--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .media-cards--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .media-cards { gap: var(--space-lg) var(--space-md); }
}

.media-card { display: flex; flex-direction: column; height: 100%; }

/* The same fixed image height as a case-study card — that is the point of the
   contract.

   The yellow rule is an OUTLINE, not a border. With `box-sizing: border-box`
   a 2px border is subtracted from the declared height, so these images came
   out 172x327 inside a 176x331 box while every other card's filled its box
   exactly — the same artwork at two different scales depending on which
   block it was in. An outline is painted outside the box and takes part in no
   layout at all, which is what a decorative rule around a picture should do.
   `.gallery__thumb` already uses one for the same reason. */
.media-card__media {
  margin: 0 0 var(--space-xs);
  outline: var(--frame-w) solid var(--yellow);
}
.media-card__title {
  font-size: var(--text-md);
  font-weight: 500;
  line-height: var(--leading-snug);
  margin-bottom: var(--space-2xs);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--card-title-lines);
  line-clamp: var(--card-title-lines);
  overflow: hidden;
}
/* Not clamped, and deliberately not: these bodies are hand-written copy of a
   consistent length, and the "Learn more" link below has to stay put. The
   card is still a fixed height because the image is. */
.media-card__body { flex: 1; font-size: var(--text-base); line-height: var(--leading-body); }
.media-card__more { margin-top: var(--space-xs); }

/* ---------- use-case cells ---------------------------------------------
   MEASURED: 336x285.6 yellow cells, 24px/16px padding, an 80x80 icon, a 24px
   heading. Three across, and always black copy on the yellow.
   ----------------------------------------------------------------------- */
.use-cases { display: grid; gap: var(--space-xs); margin: 0; padding: 0; list-style: none; }
@media (min-width: 36rem) { .use-cases { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .use-cases { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.use-case {
  display: flex;
  flex-direction: column;
  padding: var(--space-sm) var(--space-xs);
  background: var(--surface-accent);
  color: var(--black);
}
.use-case__icon { width: 5rem; height: 5rem; margin-bottom: var(--space-xs); }
.use-case__title { font-size: var(--text-lg); font-weight: 400; line-height: var(--leading-snug); margin-bottom: var(--space-2xs); }
.use-case__body { font-size: var(--text-base); line-height: var(--leading-body); }

/* ---------- numbered process ------------------------------------------
   MEASURED: a two-column grid, a narrow marker column and the copy beside
   it. The original draws each marker as an inline SVG circle with a hand-set
   number; this is an ordered list with a CSS counter, so the numbering cannot
   drift out of step with the steps.
   ----------------------------------------------------------------------- */
.process { counter-reset: step; display: grid; gap: var(--space-sm); margin: 0; padding: 0; list-style: none; }

.process__step {
  display: grid;
  grid-template-columns: 2.25rem minmax(0, 1fr);
  column-gap: var(--space-xs);
  align-items: start;
}
.process__step::before {
  counter-increment: step;
  content: counter(step);
  display: grid;
  place-items: center;
  width: 2.25rem; height: 2.25rem;
  border: 1px solid var(--black);
  border-radius: 50%;
  font-size: var(--text-sm);
  line-height: 1;
}
.process__title { font-size: var(--text-md); font-weight: 700; line-height: var(--leading-snug); margin-bottom: 0.125rem; }
.process__body { font-size: var(--text-base); line-height: var(--leading-body); }

/* ---------- comparison table ------------------------------------------
   "What makes us different": us on the left with ticks, them on the right
   with crosses. The original is a grid of loose divs; this is a real <table>,
   because it is a real table and a screen reader should be able to say which
   column a row is in.
   ----------------------------------------------------------------------- */
.compare { width: 100%; border-collapse: collapse; text-align: left; }
.compare caption { text-align: left; padding-bottom: var(--space-xs); font-size: var(--text-base); color: var(--ink-muted); }
.compare th, .compare td { padding: var(--space-xs) var(--space-2xs); border-bottom: 1px solid var(--line); vertical-align: top; }
.compare thead th { font-size: var(--text-md); font-weight: 700; border-bottom: 2px solid var(--black); }
.compare tbody td { font-size: var(--text-base); line-height: var(--leading-body); }
.compare__mark { display: inline-flex; margin-right: 0.5rem; vertical-align: -0.15em; }
.compare__mark svg { width: 1.125rem; height: 1.125rem; }
.compare__mark--yes { color: #157a3c; }   /* 4.63:1 on white */
.compare__mark--no { color: #b3261e; }    /* 5.94:1 on white */

/* ---------- quote bands -----------------------------------------------
   MEASURED: a yellow band inset 40px from each side of the viewport, with a
   large quote, the attribution beneath and sometimes a black button.
   Oversized quote marks are the original's; they are decoration, so they are
   aria-hidden and the mark characters are not read out twice.
   ----------------------------------------------------------------------- */
/* The panel owns its own padding — see .band__panel in foundation.css — so
   the section around it adds none. Two sets of vertical padding here is how a
   band ends up twice as tall as it looks in the design. */
.quote-band { padding-block: 0; }
.promo-band { padding-block: 0; }

.quote { max-width: 46rem; margin-inline: auto; }
.quote__text { font-size: clamp(1.125rem, 2.2vw, var(--text-lg)); font-weight: 400; line-height: var(--leading-mid); }
.quote__mark { font-size: 1.6em; line-height: 0; vertical-align: -0.28em; }
.quote__attr { margin-top: var(--space-md); font-size: var(--text-md); line-height: var(--leading-mid); }
.quote__name { font-weight: 700; }
.quote__role { display: block; font-weight: 400; }
.quote .button-group { justify-content: center; margin-top: var(--space-md); }

/* Two quotes side by side (Explainer Videos). Separate yellow cards rather
   than one band, so a short quote does not float in a tall box. */
.quote-pair { display: grid; gap: var(--space-xs); margin: 0; padding: 0; list-style: none; }
@media (min-width: 64rem) { .quote-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.quote-pair > li {
  display: flex;
  padding: var(--space-md) var(--space-sm);
  background: var(--surface-accent);
  color: var(--black);
}
@media (min-width: 48rem) { .quote-pair > li { padding: var(--space-lg) var(--space-md); } }
.quote-pair .quote { max-width: none; margin: 0; }

/* The rotating quote band. Every quote is in the HTML and visible with
   scripting off — the script only turns the list into one-at-a-time with a
   pair of arrows, so nothing is ever hidden behind a control that might not
   run. */
.quote-slider__list { display: grid; gap: var(--space-lg); margin: 0; padding: 0; list-style: none; }
[data-js] .quote-slider[data-carousel] .quote-slider__list { display: block; gap: 0; }
[data-js] .quote-slider[data-carousel] .quote-slider__item[hidden] { display: none; }

.quote-slider__nav {
  display: none;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  margin-top: var(--space-md);
}
[data-js] .quote-slider[data-carousel] .quote-slider__nav { display: flex; }
.quote-slider__btn {
  display: grid;
  place-items: center;
  width: 2.75rem; height: 2.75rem;
  background: none;
  border: 1px solid var(--black);
  border-radius: 50%;
  color: var(--black);
  cursor: pointer;
  transition: background-color var(--duration) var(--ease), color var(--duration) var(--ease);
}
.quote-slider__btn:hover { background: var(--black); color: var(--yellow); }
.quote-slider__btn svg { width: 1.25rem; height: 1.25rem; }
.quote-slider__status { font-size: var(--text-sm); min-width: 4rem; text-align: center; }

/* ---------- promo band -------------------------------------------------
   "90 Seconds Can Change Everything": a yellow band with the original's
   hand-drawn arrow and a very large line of copy, beside a video.
   ----------------------------------------------------------------------- */
.promo { display: grid; gap: var(--space-md); align-items: center; }
@media (min-width: 64rem) { .promo { grid-template-columns: 1fr 1fr; gap: var(--space-lg); } }

.promo__media { margin: 0; position: relative; aspect-ratio: 16 / 9; background: var(--black); }
.promo__media iframe,
.promo__media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; }

.promo__arrow { width: 6.5rem; height: auto; margin-bottom: var(--space-2xs); }
.promo__text { font-size: clamp(1.75rem, 4.4vw, var(--text-2xl)); font-weight: 400; line-height: var(--leading-tight); }
.promo__text strong { font-weight: 700; }

/* ---------- video wall -------------------------------------------------
   "Explore our portfolio": six Vimeo embeds. Consent-gated, and with the
   gate rendered per tile so a declined visitor sees six labelled links to
   Vimeo rather than six empty rectangles.
   ----------------------------------------------------------------------- */
.video-wall { display: grid; gap: var(--space-xs); margin: 0; padding: 0; list-style: none; }
@media (min-width: 36rem) { .video-wall { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .video-wall { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-sm); } }

.video-frame { position: relative; aspect-ratio: 16 / 9; background: var(--black); }
.video-frame iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* The scrim is not decoration. Without it this is white copy over whatever
   the poster happens to be, which on the Film & Animation promo band is a
   pale studio shot — the words were invisible. 0.55 black puts white at
   better than 7:1 over any poster. */
.video-frame__gate {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--space-2xs);
  padding: var(--space-sm);
  text-align: center;
  background: rgb(0 0 0 / 0.55);
  color: var(--white);
}
.video-frame__gate a { color: var(--white); }
.video-frame__gate p { font-size: var(--text-sm); line-height: 1.5; max-width: 26ch; }
.video-frame__gate .button { margin-top: var(--space-2xs); }

/* ---------- contact ---------------------------------------------------- */
.contact { display: grid; gap: var(--space-lg); }
@media (min-width: 64rem) { .contact { grid-template-columns: 0.75fr 1fr; align-items: start; gap: var(--space-xl); } }

.contact__group + .contact__group { margin-top: var(--space-md); }
.contact__label { font-size: var(--text-lg); font-weight: 700; margin-bottom: var(--space-2xs); }
.contact__address { font-size: var(--text-md); line-height: var(--leading-mid); }
.contact__address strong { font-weight: 700; }

.contact__list { display: grid; gap: var(--space-xs); margin: 0; padding: 0; list-style: none; }
.contact__row { display: flex; align-items: center; gap: var(--space-xs); }
.contact__icon { width: 1.75rem; height: 1.75rem; flex: none; }
.contact__row a { font-size: var(--text-base); }

/* The form. The original is a Webflow form posting to Webflow, which will
   not exist once this site moves — so this is a HubSpot target region with a
   complete, accessible static fallback underneath it. See
   blocks.py::contact_form for why the form is NOT consent-gated. */
.form-card { padding: var(--space-sm) 0; }
.form-card__title { font-size: var(--text-xl); font-weight: 400; margin-bottom: var(--space-2xs); }
.form-card__intro { font-size: var(--text-base); line-height: var(--leading-body); margin-bottom: var(--space-md); }

.form { display: grid; gap: var(--space-sm); }
.form__notice { padding: var(--space-xs); background: var(--cream); font-size: var(--text-sm); line-height: 1.6; }
.form__row { display: grid; gap: var(--space-sm); }
@media (min-width: 36rem) { .form__row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

.form__field { display: grid; gap: 0.375rem; }
.form__label { font-size: var(--text-sm); font-weight: 700; }
.form__optional { font-weight: 400; color: var(--ink-muted); }

/* The original's field is a bottom-ruled line rather than a box. Kept — but
   the rule is 2px black rather than 1px grey, because WCAG 1.4.11 wants 3:1
   for an interactive boundary and the original's grey measures 1.4:1. */
.form__input {
  width: 100%;
  padding: 0.625rem 0.25rem;
  background: var(--white);
  border: 0;
  border-bottom: 2px solid var(--control-line);
  border-radius: 0;
  font: inherit;
  font-size: var(--text-base);
  color: var(--ink);
}
.form__input--area { resize: vertical; min-height: 7rem; }
.form__input--select { appearance: none; padding-right: 2rem; }

.form__field--check { display: flex; align-items: flex-start; gap: 0.625rem; }
.form__check { margin-top: 0.3rem; width: 1.125rem; height: 1.125rem; accent-color: var(--black); }
.form__check-label { font-size: var(--text-sm); line-height: 1.6; }
.form__legal { font-size: var(--text-sm); }
.form__required-note { font-size: var(--text-sm); color: var(--ink-muted); }

/* ---------- mailing list band -----------------------------------------
   Full-bleed yellow on every page, at the client's direction — the archived
   band is inset 40px from the viewport. The copy stays on the content measure
   inside it, so only the colour runs to the edges.
   ----------------------------------------------------------------------- */
.mailing { text-align: center; }
.mailing__text { font-size: clamp(1.125rem, 2.2vw, var(--text-lg)); line-height: var(--leading-mid); max-width: 46rem; margin-inline: auto; }
.mailing .button-group { justify-content: center; }

/* =========================================================================
   CASE STUDIES
   The collection index reuses the filter machinery from the Ludic Group
   Insights page — every card is in the HTML at build time, so the page is
   complete and indexable with JavaScript off and the filter only hides and
   shows. The original's filter is a Webflow/Finsweet script that renders the
   list client-side, which means no cards at all without it.
   ========================================================================= */
/* The filter panel was touching the yellow hero banner above it and the first
   row of cards below it — a 2px black box wedged between two other blocks
   reads as part of whichever one it happens to touch. It now sits in its own
   band of cream with real space on both sides.

   The space is on a wrapper rather than as a margin on .filters, so the cream
   field runs continuously from the banner down to the last card and the panel
   floats in it. A margin on the panel itself would have left a white seam. */
.case-index { background: var(--cream); padding-bottom: var(--space-xl); }

.case-index__controls { padding-block: var(--space-lg); }
@media (min-width: 64rem) { .case-index__controls { padding-block: var(--space-xl); } }

.filters {
  display: grid;
  gap: 0;
  border: 2px solid var(--black);
  background: var(--white);
}
.filters__row {
  display: grid;
  gap: var(--space-2xs);
  padding: var(--space-sm);
  border-bottom: 1px solid var(--line);
}
.filters__row:last-of-type { border-bottom: 0; }
@media (min-width: 48rem) {
  .filters__row { grid-template-columns: 9rem 1fr; gap: var(--space-sm); align-items: baseline; }
}

.filters__label,
.filters__legend { margin: 0; font-size: var(--text-md); font-weight: 500; line-height: 1.3; }

.filters__input {
  width: 100%;
  max-width: 30rem;
  height: 2.75rem;
  padding: 0.5rem 0.75rem;
  background: var(--white);
  border: 2px solid var(--control-line);
  border-radius: 0;
  font: inherit;
  font-size: var(--text-base);
  color: var(--ink);
}
.filters__input::placeholder { color: var(--mid-grey); }

.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0; padding: 0; list-style: none; }

/* The original's filter control is a checkbox with a bordered label. These
   are buttons with aria-pressed, and the pressed state is a filled chip —
   never colour alone. */
.chip {
  display: inline-flex;
  align-items: baseline;
  gap: 0.375rem;
  padding: 0.375rem 0.875rem;
  background: var(--white);
  border: 2px solid var(--control-line);
  color: var(--ink);
  font-family: inherit;
  font-size: var(--text-sm);
  line-height: 1.4;
  cursor: pointer;
  transition: background-color var(--duration) var(--ease), color var(--duration) var(--ease);
}
.chip:hover { background: var(--yellow); }
.chip[aria-pressed="true"] { background: var(--black); border-color: var(--black); color: var(--white); }
.chip__count { font-size: var(--text-xs); color: var(--mid-grey); }
.chip[aria-pressed="true"] .chip__count { color: rgb(255 255 255 / 0.75); }

.filters__status {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-xs);
  padding: var(--space-xs) var(--space-sm);
  background: var(--cream);
  border-top: 1px solid var(--line);
}
.filters__count { font-size: var(--text-sm); color: var(--ink-muted); }
.filters__clear { font-size: var(--text-sm); font-weight: 600; }

/* Stretch is back, and it is now a no-op: every card is the same height by
   construction, so there is nothing to stretch. It stays as the belt to the
   fixed heights' braces — a sub-pixel rounding difference between two cards
   should equalise rather than show. */
.case-grid {
  display: grid;
  grid-auto-rows: 1fr;
  gap: var(--space-sm);
  margin: 0;
  padding: 0;
  list-style: none;
}
@media (min-width: 36rem) { .case-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .case-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

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

.case-card__inner {
  position: relative;
  display: flex;
  flex-direction: column;
  height: 100%;
  background: var(--white);
  transition: transform var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
}
.case-card__inner:hover { transform: translateY(-2px); box-shadow: 0 10px 28px rgb(0 0 0 / 0.10); }
@media (prefers-reduced-motion: reduce) { .case-card__inner:hover { transform: none; } }

/* A FIXED text height, computed from the clamp line counts rather than typed
   as a magic number — change --card-title-lines and this follows. The terms
   are the padding, the category line, the three gaps between the four
   children, and the clamped title and blurb at their own line-heights.

   `min-height`, not `height`: the copy is already clamped so it cannot
   overflow, and if a future change makes it taller the card grows rather than
   clipping words with no ellipsis to say so. */
.case-card__text {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  padding: var(--space-sm);
  min-height: calc(
      2 * var(--space-sm)
    + 1.4 * var(--text-xs)
    + 2 * 0.375rem
    + var(--card-title-lines) * var(--leading-snug) * var(--text-md)
    + var(--card-blurb-lines) * 1.6 * var(--text-sm));
}

.case-card__cat {
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  line-height: 1.4;
}
.case-card__title {
  font-size: var(--text-md);
  font-weight: 500;
  line-height: var(--leading-snug);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--card-title-lines);
  line-clamp: var(--card-title-lines);
  overflow: hidden;
}
.case-card__link { text-decoration: none; transition: color var(--duration) var(--ease); }
/* Stretch the link over the card so the whole card is the target, without
   nesting interactive elements. */
.case-card__link::after { content: ""; position: absolute; inset: 0; }
.case-card__inner:hover .case-card__link,
.case-card__link:focus-visible { text-decoration: underline; }

.case-card__blurb {
  font-size: var(--text-sm);
  line-height: 1.6;
  color: var(--ink-muted);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--card-blurb-lines);
  line-clamp: var(--card-blurb-lines);
  overflow: hidden;
}

/* Every thumbnail in the collection is still on the Webflow CDN. Until the
   files arrive the card renders this placeholder box, which holds the same
   ratio the real image will, so nothing reflows when they land. See
   _build/MISSING-ASSETS.md. */
/* THE CARD CONTRACT — see the --card-* tokens in foundation.css.

   Every card on the site is a FIXED-HEIGHT text area plus a FIXED-HEIGHT
   image area. Fix both and the consequences all fall out: every card in a row
   is the same height, every image starts and ends on the same line as its
   neighbours', there is no slack for a gap to open in, and the rule is one
   rule rather than a judgement per card.

   Three earlier attempts and why each failed, because each looks reasonable:

   1. `margin-top: auto` on the image inside a stretched card. Aligns every
      image to the card's bottom edge, and puts the row's slack in the middle
      of the card — up to 72px of white between the copy and the picture.
   2. `object-fit: contain` on a fixed ratio. No crop, but the thumbnails run
      1.37:1 to 2.27:1, so almost every one letterboxed inside its own box.
   3. Natural-height cards with `align-items: start`. No gap anywhere, but the
      cards end at four different heights.

   What is given up is text: a title longer than two lines is clamped with an
   ellipsis, and the full one is on the card's own page. That is the same kind
   of cost as cropping the image, and it is the price of a grid that lines up.
   ----------------------------------------------------------------------- */
/* Height, background and clipping are shared. MARGIN IS NOT — this rule sits
   later in the file than the media-card block, so a `margin: 0` here silently
   won over `.media-card__media { margin-bottom: 1rem }` and closed the 16px
   gap between those images and their titles. Each card type owns its own
   spacing; only the box is shared. */
.case-card__media,
.media-card__media {
  height: var(--card-media-h);
  background: var(--cream);
  overflow: hidden;
}

/* CROP TO FILL. `height: 100%` with `object-fit: cover` fills a fixed box
   whatever the source's shape, which is what makes one image height possible
   across thumbnails that run from 1.37:1 to 2.27:1. No aspect-ratio here: the
   box's height is the fixed thing and its width is whatever the grid column
   is, so declaring a ratio as well would fight it. */
.case-card__media img,
.media-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.case-card__placeholder {
  display: grid;
  place-items: center;
  height: 100%;
  background: repeating-linear-gradient(
    135deg, var(--cream), var(--cream) 10px, #e8e8e8 10px, #e8e8e8 20px);
  color: var(--ink-muted);
  font-size: var(--text-xs);
}

.case-empty { margin-top: var(--space-md); font-size: var(--text-md); }
.case-index__feed { margin-top: var(--space-lg); }

/* ---------- one case study --------------------------------------------- */
.case { padding-block: var(--space-md) var(--space-xl); }

.breadcrumb { margin-bottom: var(--space-sm); }
.breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--text-xs);
  color: var(--ink-muted);
}
.breadcrumb__list li + li::before { content: "/"; margin-right: 0.5rem; color: var(--mid-grey); }
.breadcrumb__list a { color: var(--ink-muted); text-decoration: none; }
.breadcrumb__list a:hover { color: var(--black); text-decoration: underline; }
.breadcrumb__list [aria-current="page"] { color: var(--black); }

.case__cat { font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: var(--space-2xs); }
.case__title { font-size: clamp(1.75rem, 3.6vw, var(--text-2xl)); font-weight: 400; line-height: var(--leading-snug); }

.case__figure { margin: var(--space-md) 0 0; }
.case__figure img { width: 100%; }

/* MEASURED: the archived template puts the blurb under the hero image as one
   large semibold line, then the three rich-text fields side by side. */
.case__blurb {
  margin-top: var(--space-md);
  font-size: var(--text-lg);
  font-weight: 600;
  line-height: var(--leading-mid);
  max-width: 46ch;
}

/* Three columns, because the CMS has three rich-text fields and the archived
   template shows all three in one row. Two columns plus a band for the third
   left a page-height hole beside a short Challenge column. */
.case__body { display: grid; gap: var(--space-md); margin-top: var(--space-lg); }
@media (min-width: 48rem) {
  .case__body--2, .case__body--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 64rem) {
  .case__body { gap: var(--space-lg); }
  .case__body--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.case__body .prose h2 { margin-top: 0; }
/* An embedded film inside a column needs no extra air: the placeholder
   already holds a 16:9 box. */
.case__body .video-frame { margin-block: var(--space-xs); }

.case__pending { margin-top: var(--space-lg); font-size: var(--text-sm); color: var(--ink-muted); }

.case__gallery-title {
  margin-top: var(--space-xl);
  font-size: var(--text-lg);
  font-weight: 700;
  line-height: var(--leading-snug);
}
/* auto-fit, not a fixed column count. These galleries hold anything from one
   image to six, and a fixed three-column grid renders a single image at a
   third of the width with two thirds of empty space beside it. auto-fit gives
   one image the full measure, two a half each, and three or more the thirds
   the design wants. */
.case__gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
  gap: var(--space-xs);
  margin: var(--space-sm) 0 0;
  padding: 0;
  list-style: none;
}
.case__gallery img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; background: var(--cream); }
.case .button-group { justify-content: center; margin-top: var(--space-xl); }

/* --- prose, for copy generated from Markdown --- */
.prose > :first-child { margin-top: 0; }
.prose h2 { font-size: var(--text-lg); font-weight: 700; line-height: var(--leading-snug); margin: var(--space-md) 0 var(--space-2xs); }
.prose h3 { font-size: var(--text-md); font-weight: 700; margin: var(--space-sm) 0 var(--space-2xs); }
.prose p { margin-bottom: var(--space-xs); font-size: var(--text-base); line-height: var(--leading-body); }
.prose ul, .prose ol { margin: 0 0 var(--space-xs); padding-left: 1.35em; }
.prose li { margin-bottom: 0.4em; line-height: var(--leading-body); }
.prose a { text-decoration: underline; }
.prose strong { font-weight: 700; }
.prose blockquote {
  margin: var(--space-md) 0;
  padding-left: var(--space-xs);
  border-left: 3px solid var(--yellow);
  font-size: var(--text-md);
  line-height: var(--leading-mid);
}
.prose hr { margin: var(--space-md) 0; border: 0; border-top: 1px solid var(--line); }
.prose > :last-child { margin-bottom: 0; }

/* Legal pages: one long column, generous measure, no decoration. */
.legal { padding-block: var(--space-lg) var(--space-xl); }
.legal__updated { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-md); }

/* ---------- 404 --------------------------------------------------------- */
.notfound { padding-block: var(--space-xl); text-align: center; }
.notfound__code { font-size: var(--text-3xl); font-weight: 700; line-height: 1; margin-bottom: var(--space-xs); }
.notfound__links { display: grid; gap: 0.5rem; justify-content: center; margin: var(--space-md) 0 0; padding: 0; list-style: none; }
