/* ===== Case study page — v2 zigzag =====
   One stylesheet per page-type, same convention as css/sections/*.css.
   Used by work/<slug>/index.html.

   LAYOUT IS TRANSFERRED FROM FIGMA — file f3e3QGIBAgCZ9S4SZ2vfsd,
   node 445-40116 "Case layout — v2 zigzag". It replaces the earlier
   single-column node 359-13046: same content, half the height (8728 vs
   12947), because most sections now run as two columns that alternate
   which side carries the text.

   Numbers read off that auto-layout, not invented:

     • section gap      96px   (measured: all nine gaps between the ten
                                section frames are exactly 96; v1 used 120)
     • side padding    120px
     • content width  1200px   (1440 − 2×120)
     • two columns    560 + 560 with an 80px gutter (cols sit at x=0, x=640)
     • title → body     24px
     • paragraph gap    20px

   TYPE is Figma's own text styles, as literals — this page is a faithful
   transfer and the site scale has no 28 or 48 step. Note the hero title
   dropped to 48/1.5 in v2 (v1 had 72/1.15).

   COLOUR is deliberately NOT taken from the mock. v2 is drawn on a dark
   board (#FFFFFF title, #B3B2C7 secondary); pinning those would give a page
   that ignores the theme switch. Everything maps to the site's semantic
   tokens instead, which is what the brief asked for. */

.case {
  --case-measure: 720px;   /* single-column text blocks */
  --case-gutter: 80px;     /* the zigzag columns are 1fr each — at the capped
                              1200px content that gives the mock's 560 + 560 */
  --case-content: 1200px;
  --case-pad: 120px;
  --case-gap: 96px;
  position: relative;
  z-index: var(--z-content);
  /* Run-out space at the end of the page. It used to come from the closing
     footer's padding; that element is gone, so the page holds it itself and
     the last section doesn't finish flush against the bottom. */
  padding-bottom: 80px;
}

/* Wide wrapper — every section sits on this. */
.case__wrap {
  width: 100%;
  max-width: calc(var(--case-content) + var(--case-pad) * 2);
  margin-inline: auto;
  padding-inline: var(--case-pad);
}

/* Text wrapper for the few full-width blocks that stay single-column. */
.case__inner {
  width: 100%;
  max-width: calc(var(--case-measure) + var(--case-pad) * 2);
  margin-inline: 0 auto;
  padding-inline: var(--case-pad);
  text-align: left;
}

/* --- top chrome: not from Figma; the mock is a bare landing. --- */
.case__topbar {
  position: sticky;
  top: 0;
  z-index: var(--z-hero-controls);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  padding: var(--space-4) var(--case-pad);
  background: var(--c-surface-veil);
  backdrop-filter: blur(10px);
  transition: background var(--theme-dur) var(--ease-out);
}
.case__back {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: 16px;
  color: var(--c-ink-soft);
  text-decoration: none;
  transition: color var(--motion-hover);
}
.case__back:hover { color: var(--c-ink); }
.case__back svg { width: 1rem; height: 1rem; flex-shrink: 0; }

/* --- sections --- */
.case__section { padding-block: calc(var(--case-gap) / 2); }
.case__section > * + * { margin-top: 24px; }

/* --- THE zigzag row. Which side the text lands on is the modifier.
   The columns are 1fr, NOT the mock's literal 560px. Two fixed 560s plus the
   80px gutter demand 1200px of content, which only exists at a >=1440px
   window; at 1280 (and every laptop between 1201 and 1439) the row overflowed
   its wrapper by 160px and body's `overflow-x: hidden` silently CLIPPED it —
   the right column lost its last ~40px of text mid-word. 1fr tracks still
   resolve to exactly 560 + 560 once the content hits its 1200px cap, so the
   full-width rendering is unchanged. `minmax(0, ...)` because a bare 1fr
   floors at min-content and a long unbroken word would overflow again. --- */
.case__row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--case-gutter);
  align-items: start;
}
/* .case__row--flip painted the first child on the right and the second on the
   left without reordering the markup, so a zigzag kept its reading order. It
   went with section 06, its only user. .case__tl--flip below is the timeline's
   own version and is unaffected. */

.case__col > * + * { margin-top: 24px; }

/* Opt one item out of the row's top alignment. 02's photo hangs from the
   bottom of its row rather than the top — with a fixed-height picture beside a
   variable-height paragraph, which edge they share is a real design choice, so
   it is spelled out in the markup rather than inferred. */
.case__self-end { align-self: end; }

/* --- 01 Hero: text left, phone right (Figma: 802 + 332, gap 24) --- */
.case__hero {
  display: grid;
  grid-template-columns: 802px 332px;
  gap: 24px;
  align-items: start;
  padding-top: 60px;
  padding-bottom: calc(var(--case-gap) / 2);
}
/* The measure the hero's headline AND its lede both answer to. Figma caps the
   title at 646 inside an 802 column; the lede was left uncapped and ran the
   full 802, overhanging the headline it sits under by 156px at 1440. Shared as
   one property rather than the number twice, so the two edges cannot drift. */
.case__hero-text { --case-hero-measure: 646px; }
.case__hero-text > * + * { margin-top: 24px; }
.case__eyebrow {                              /* Case/Meta — 16 / 1.5 */
  font-size: 16px;
  font-weight: 500;
  line-height: 1.5;
  color: var(--c-ink-soft);
}
.case__title {                                /* 48 / 1.5 bold — v2 dropped it from 72 */
  font-size: 48px;
  font-weight: 700;
  line-height: 1.5;
  color: var(--c-ink);
  max-width: var(--case-hero-measure);
}
.case__lede {                                 /* Case/Subtitle — 28 / 1.4 */
  font-size: 28px;
  font-weight: 500;
  line-height: 1.4;
  color: var(--c-ink-soft);
  max-width: var(--case-hero-measure);
}

/* --- headings --- */
.case__section-title {                        /* Case/Section Title — 48 / 1.2 */
  font-size: 48px;
  font-weight: 600;
  line-height: 1.2;
  color: var(--c-ink);
}
.case__subtitle {                             /* Case/Subtitle — 28 / 1.4 */
  font-size: 28px;
  font-weight: 500;
  line-height: 1.4;
  color: var(--c-ink);
}

/* --- body copy --- */
.case__body > * + * { margin-top: 20px; }
.case__body p,
.case p {
  font-size: 18px;
  line-height: 1.5;
  color: var(--c-ink);
}
.case__muted, .case__muted p { color: var(--c-ink-soft) !important; }

/* --- tinted card (Figma: radius 12, padding 48 / 32) --- */
.case__card {
  background: var(--c-surface-veil);
  border: 1px solid var(--c-line);
  border-radius: 12px;
  padding: 48px;
}
.case__card > * + * { margin-top: 16px; }
.case__card p { color: var(--c-ink-soft); }

/* --- 05: constraint cards, 3 across, two rows (unchanged in v2) --- */
.case__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
}
.case__grid .case__card { padding: 32px; }
.case__grid .case__card > * + * { margin-top: 12px; }
/* The card's title, bolder and a step above its description.
   THREE classes deep on purpose. `.case__subtitle` is a <p>, so the page-wide
   `.case p { font-size: 18px }` — (0,1,1) against the class's (0,1,0), and
   declared later — silently wins: every subtitle on this page renders at the
   body size, which is why these titles stopped reading as titles at all. Any
   fix here has to out-weigh that rule, not just restate a size. */
.case__grid .case__card .case__subtitle {
  font-size: 22px;
  font-weight: 700;
  line-height: 1.3;
}

/* .case__tiles / .case__tile — the dashed caption boxes standing in for
   artwork Figma never filled — went with section 06, their only user. */

/* --- 09 + 11: stat cards --- */
.case__stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 24px;
}
.case__stat {
  background: var(--c-surface-veil);
  border: 1px solid var(--c-line);
  border-radius: 12px;
  padding: 32px;
}
.case__stat dt {
  font-size: 48px;
  font-weight: 600;
  line-height: 1.2;
  color: var(--c-ink);
}
.case__stat dd {
  font-size: 18px;
  line-height: 1.5;
  color: var(--c-ink-soft);
  margin-top: 8px;
}

/* --- 09: category table ---
   Rules BETWEEN the rows and nowhere else — three lines for four rows, none
   above Model and none under Accessories. `+` rather than a border-top on
   every item: the adjacent-sibling selector cannot match the first row, so the
   leading line is impossible by construction rather than removed afterwards.
   The trailing one is gone with the `.case__rows` border-bottom that drew it.

   `align-items: baseline` is what puts each explanation on the same line as
   its term. The two columns are 28px and 18px, and grid's default `stretch`
   starts both boxes at the row's top — which leaves the smaller text riding
   high against the larger, reading as two lists rather than one row. Baseline
   sits them on a shared line, so the eye crosses from "Model" to what Model
   means without stepping. */
.case__row-item {
  display: grid;
  grid-template-columns: 280px 1fr;
  gap: 40px;
  padding-block: 24px;
  align-items: baseline;
}
.case__row-item + .case__row-item {
  border-top: 1px solid var(--c-line);
}
.case__row-item dt {
  font-size: 28px;
  font-weight: 500;
  line-height: 1.4;
  color: var(--c-ink);
}
.case__row-item dd {
  font-size: 18px;
  line-height: 1.5;
  color: var(--c-ink-soft);
}

/* --- 07: the zigzag timeline.
   Figma runs a rail down the centre of the board (x≈719) with the entries
   alternating sides. Here one continuous border draws the rail and each
   entry occupies one half of a two-column grid.

   The rail needs a WIDER gutter than the prose rows: Figma's timeline columns
   are 541 with 118 between them (text ends at x=660, the visual opposite
   starts at 778, rail centred at 719), where a .case__row is 560/80. Sharing
   the 80 would run the copy right up against the rail. --- */
.case__timeline {
  --case-rail-gutter: 118px;

  /* Where an entry's dot sits inside its own row: its top padding plus half a
     heading line. Declared HERE rather than on .case__tl because the rail's
     opening ramp has to end on the first dot, so the rail needs the same two
     numbers the dot is positioned from. .case__tl inherits them. */
  --tl-pad: 24px;
  --tl-head-mid: 19.6px;   /* half of the h3's line box: 28px x 1.4 */

  /* Empty run of rail above the first entry, so the opening ramp has somewhere
     to happen. Without it the ramp eats into the first entry and the first dot
     ends up sitting in the faint part of its own line — a bullet with no line
     attached to it. This is the gap that opens above "Early 2020". */
  --case-rail-lead: 48px;

  /* How far the rail takes to reach full strength at each end. LENGTHS, not
     percentages: the list is ~1060px wide-screen and about twice that once it
     stacks, and a percentage would make the taper twice as long there — the
     same rail reading as two different graphics.

     The two ends are separate because the opening ramp has to finish exactly
     ON the first dot while the closing one starts well after the last. The
     in-ramp is COMPUTED from the same three numbers the dot is placed from
     (see .case__tl::before), so the two cannot drift apart — including at
     769-1023, where the entries are already two columns but the dot is still
     on the heading rather than at the row's centre. The >=1024 block below
     moves that dot and re-states this to match. */
  --case-rail-fade-in: calc(var(--case-rail-lead) + var(--tl-pad) + var(--tl-head-mid));
  --case-rail-fade-out: 140px;

  /* ONE colour for the rail, the dots and the years — full --c-ink, the same
     value .case__tl h3 is painted in. The three read as one piece of drawing
     that way rather than as a graphic with a caption next to it.

     This went through two weaker versions first. It began as --c-line (10%
     ink) with a heavier bead on it, which left the line too faint beside its
     own dots; then rail and dots were unified on a color-mix of ink and page,
     which still sat them behind the years. Full ink retires the mix, the
     per-theme percentage that went with it and the @supports guard it needed
     — --c-ink already flips with the theme on its own. */
  --case-dot: var(--c-ink);
  --case-dot-size: 11px;

  /* The dash tile, straight off the Figma stroke panel: dash 6, gap 4, round
     dash cap — so a 6px stadium in a 10px tile. It has to be a SHAPE, which
     is why it is an SVG and not the repeating-linear-gradient this started
     as: gradient stops can only cut straight across, so every dash ended
     square.

     It rides in the MASK rather than the background, so the SVG's own fill
     never shows and --case-dot stays the only place the colour is written.
     `rx=1` is half the 2px width, which is what makes the ends true
     semicircles rather than merely soft corners. Intrinsic width/height on
     the <svg> means it tiles at exactly 2x10 with no mask-size to keep in
     step — dash, gap and cap all live in this one line. */
  --case-rail-dashes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='2' height='10'%3E%3Crect width='2' height='6' rx='1' fill='%23000'/%3E%3C/svg%3E");

  /* Named once because it is spelled twice below, prefixed and not. */
  --case-rail-fade: linear-gradient(to bottom,
        transparent 0,
        #000 var(--case-rail-fade-in),
        #000 calc(100% - var(--case-rail-fade-out)),
        transparent 100%);

  position: relative;
  list-style: none;
  padding: 0;
  padding-top: var(--case-rail-lead);
}
.case__timeline::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 8px;
  width: 2px;
  margin-left: -1px;
  /* Flat colour; the mask is what turns it into dashes. Not a dashed BORDER:
     a native one leaves the dash and gap lengths to the browser — derived
     from the border width, so a 2px rail gets ~6px specks — and the ratio
     differs between engines. */
  background: var(--case-dot);
  /* The rail alone steps back to 50%; the dots stay at full ink, so they read
     as the marks and the line as the thing joining them. On the pseudo-element
     rather than in --case-dot, which the dots share. */
  opacity: .5;

  /* Two mask layers, INTERSECTED: the dashes decide where there is rail at
     all, the fade decides how much of it shows at the ends. Intersect and not
     the default `add` — added, the fade's opaque middle would union with the
     gaps and hand back a solid line.

     The fade exists so the rail arrives and leaves rather than being cut off
     square. It is a mask rather than gradient stops in the background because
     a mask only ever touches alpha: the colour stays exactly --case-dot
     instead of being named a second time and faded towards rgba(0,0,0,0). */
  -webkit-mask-image: var(--case-rail-dashes), var(--case-rail-fade);
          mask-image: var(--case-rail-dashes), var(--case-rail-fade);
  -webkit-mask-repeat: repeat-y, no-repeat;
          mask-repeat: repeat-y, no-repeat;
  -webkit-mask-composite: source-in;   /* the legacy keyword for intersect */
          mask-composite: intersect;
}
.case__tl {
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));   /* see .case__row */
  gap: var(--case-rail-gutter);
  align-items: center;
  padding-block: var(--tl-pad);
}
/* One <li> is one MOMENT: its text and its visual share a band and sit on
   opposite sides of the rail, the way the mock places them. Markup order is
   always text-then-visual so the reading order stays sane; --flip only
   changes which side each is painted on. */
.case__tl > *:first-child  { grid-column: 1; }
.case__tl > *:nth-child(2) { grid-column: 2; grid-row: 1; }
.case__tl--flip > *:first-child  { grid-column: 2; }
.case__tl--flip > *:nth-child(2) { grid-column: 1; }
/* an entry with no visual still has to pick a side */
.case__tl--right > *:only-child { grid-column: 2; }

/* The copy leans on the rail: an entry painted to the LEFT of it is
   right-aligned, one painted to the right is left-aligned, so every
   paragraph's ragged edge faces outward and its flush edge faces the rail.
   That is the whole reason the mock reads as a spine rather than two lists. */
.case__tl > .case__tl-text            { text-align: right; }
.case__tl--flip > .case__tl-text,
.case__tl--right > .case__tl-text     { text-align: left; }

/* the dot on the rail. Literally the rail's own colour (--case-dot), so it
   reads as a thickening of the line rather than a separate mark sitting on
   it — which is also why it shrank: at Figma's 16 it was a bead the dashes
   were merely arriving at. */
.case__tl::before {
  content: '';
  position: absolute;
  left: 50%;
  /* Level with the HEADING, which is where the mock puts it. This position is
     right whenever the text is the tallest thing in the row — true for the
     entry with no picture at any width, and true for every entry once the
     columns get narrow enough that the copy wraps past the box. The wide-screen
     case, where the box is taller, is handled in the min-width block below. */
  top: calc(var(--tl-pad) + var(--tl-head-mid));
  /* One number per breakpoint: the centring margin is derived, not typed.
     Typed, it drifted — the base said -8px on both axes while the stacked
     override only corrected margin-left, so the smaller dot there sat 2px
     high of where its own `top` put it. */
  width: var(--case-dot-size);
  height: var(--case-dot-size);
  margin: calc(var(--case-dot-size) / -2) 0 0 calc(var(--case-dot-size) / -2);
  border-radius: 50%;
  background: var(--case-dot);
}

/* Wide screens only. Figma lands three things on one horizontal per entry: the
   dot, the middle of the white box, and the middle of the entry's HEADING — not
   the middle of the text block. Centring the block puts its heading well above
   that line (77px out, measured), so the block is nudged down by half its own
   height less half a heading line, and the dot moves to the row's centre to
   meet it.

   Gated at 1024 because the nudge is a TRANSFORM, which moves the text without
   growing the row. That is only safe while the box is the taller element. Below
   1024 the columns are narrow enough that the copy wraps past the box and the
   text drives the row height — there, the nudge pushed the last lines straight
   out of the bottom and underneath the NEXT entry's picture (measured: 49px and
   36px of overlap at a 772px window). Under 1024 the text sits where it falls
   and the dot stays on the heading, per the base rule above. */
@media (min-width: 1024px) {
  .case__tl:not(.case__tl--right) > .case__tl-text {
    transform: translateY(calc(50% - var(--tl-head-mid)));
  }
  .case__tl:not(.case__tl--right)::before { top: 50%; }

  /* The first dot has just moved to the middle of a tall row, so it supplies
     its own runway and the rail needs no lead of its own. 140px is measured:
     that dot lands 154px below the rail's top at 1440, so the ramp finishes
     just short of it. It cannot be computed the way the narrower layouts do —
     50% of a row whose height comes from a picture is not a number CSS can
     hand to a gradient. */
  .case__timeline {
    --case-rail-lead: 0px;
    --case-rail-fade-in: 140px;
  }
}
.case__tl h3 {
  font-size: 28px;
  font-weight: 500;
  line-height: 1.4;
  color: var(--c-ink);
  margin-bottom: 12px;
}
.case__tl p { color: var(--c-ink-soft); }

/* --- the timeline's white card. ONE size for all three (Figma: every
   "Visual —" frame is 520x250), which is what stops a wide product shot and a
   tall sketch sheet from reading as two different components. The ratio does
   the work, not a fixed height, so the three stay equal at every width.

   `contain` rather than `cover`: these are drawings with their own margins and
   nothing in them is croppable at this point — the two that Figma DOES crop
   were cropped in the file itself, so what arrives here always fits whole. --- */
.case__tl-fig {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
  width: 100%;
  aspect-ratio: 520 / 250;
  padding: 16px 24px;
  /* The same hairline every other container on the page carries
     (.case__card, .case__stat, .case__quote: 1px solid var(--c-line)) — but
     written LITERALLY, like the background right below it, and for the same
     reason. --c-line flips to 10% WHITE on the dark theme, and this box does
     not flip: it is white in both. A white-on-white border is no border, so
     the box would quietly lose its outline on one theme out of two. 10% black
     is exactly what --c-line resolves to on light, so this is that hairline
     pinned to the surface it actually sits on. */
  border: 1px solid var(--black-a10);
  border-radius: 20px;
  background: #fff;   /* literal: the artwork is drawn on white in both themes */
}
.case__tl-fig img {
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  min-width: 0;
  object-fit: contain;
  border-radius: 0;   /* the box is the rounded thing, not the drawing */
}

/* The Result row deliberately has NO plate: its re-export is transparent and
   sits straight on the page. The figures were drawn to hold contrast on any
   background — which is the whole argument of the case — so a white box under
   them was both redundant and the one place the page contradicted the text. */

/* --- figures --- */
.case figure { margin: 0; }
.case figure img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 20px;
}
.case figcaption {
  margin-top: 12px;
  font-size: 14px;
  line-height: 1.5;
  color: var(--c-ink-soft);
}

/* One photo, softer than the rest: it is the widest image on the page and the
   only photograph, so it carries a larger corner than the UI shots do. */
/* `.case` is carried on both of the radius overrides below purely for weight:
   the base rule is `.case figure img` (0,1,2), so a bare `.case__photos img`
   (0,1,1) loses to it and silently does nothing. */
.case .case__figure--soft img { border-radius: 40px; }

/* 03: three photos inside one 560 column.
   The tracks are PROPORTIONAL, not equal. All three sources are 673px tall and
   differ only in width (325 / 330 / 336), and Figma lines them up on a shared
   height of 341 with widths of 165 / 167 / 170. Equal `1fr` tracks give the
   opposite — equal widths and three different heights, so the row of phones
   loses its baseline. Weighting each track by its source width reproduces the
   shared height exactly, and keeps doing so at any column width. */
.case__photos {
  display: grid;
  grid-template-columns: 325fr 330fr 336fr;
  align-items: start;
  /* Full column width, flush to the column — NOT capped and centred, which is
     what a brief 70% experiment did. Measured off the Figma guide: the three
     phones there run 267 : 271 : 276 across a column whose text opposite is the
     same width, i.e. they fill their half of the row and line up with its edge.
     Centring them inside a narrower box left the trio floating away from both
     the text and the column, which is what read as misaligned.

     Their width ratios are also the guide's — 0.967 : 0.982 : 1.0 — the same
     ones the tracks above already encode, so the proportional grid needed no
     change when the artwork was replaced. */
  gap: 29px;
}
/* tighter than the page default: these are three narrow phone shots, and a
   20px corner on a 165px-wide image eats into the screen it is showing */
.case .case__photos img { border-radius: 12px; }

/* 07: the Worlds × Tiers card, full width.
   Figma's card is 1200 wide with 56 padding and exactly 24 between header,
   table and legend — the 8px that used to sit between the four row exports is
   gone with them, the gutters are inside the single table asset now. */
/* --matrix-pad rather than a bare 56: the table below has to cancel exactly
   this value to reach the card's inner edge, and the number changes at the
   mobile breakpoint. One variable, so the two can never disagree. */
.case__matrix { --matrix-pad: 56px; padding: var(--matrix-pad); overflow: hidden; }
.case__matrix > * + * { margin-top: 24px; }
/* The table is a <figure>, and `.case figure { margin: 0 }` is (0,1,1) — it
   beats the rule above and was eating the figure's top margin ALONE. The
   legend below kept its 24, so the line of text above the grid sat hard
   against the column heads while the legend had room. Two classes here to
   out-weigh it. 32 rather than the card's 24 because the grid's own 10px of
   border-spacing sits inside the figure, so a flat 24 still left the copy
   reading tight against "Base / Common / Rare / Epic". */
.case__matrix > .case__matrix-table { margin-top: 32px; }
/* The card's two runs of text — the line above the grid and the legend below
   it — are centred on the card. The grid between them is a centred object, and
   left-ragged text either side of it read as two loose ends. */
.case__matrix > div { text-align: center; }
/* ===== the Worlds x Tiers matrix =====
   Markup + all the geometry constants are explained on the <table> in
   work/yumoji/index.html. Everything here is theme-driven; nothing is taken
   from the export's own palette except the four world dots, which are set
   inline because they are identity colours, not surfaces. */
.tiers {
  /* Figma's table, in its own units — every rule below derives from these */
  --sheet-w: 1088;   --sheet-h: 866;
  --col-0: 150;      --col-step: 237;
  --row-0: 76;       --row-step: 200;
  /* The window onto one cell's figure pair. Inset far enough to clear the
     plate's 18px corner radius, so a sprite window never catches a corner and
     never shows the export's own plate as an edge — the residual tint inside
     is flat 2-5.5% black across the whole window, i.e. invisible. */
  --win-x: 30;       --win-y: 16;
  --win-w: 167;      --win-h: 158;

  width: 100%;
  table-layout: fixed;
  /* 227 cell in a 237 column, 190 in a 200 row: the mock's gutter IS 10, and
     border-spacing is what draws it.

     NOT `collapse` with cell padding, which is what this was: under the
     collapsing border model `border-radius` DOES NOT APPLY to table cells, so
     the plates came out all but square however large the radius was set. The
     one cost of `separate` is that the spacing also rings the outside of the
     table — 10px of extra inset inside the card's 56px padding, which is
     invisible, and far cheaper than the negative margin that would cancel it
     (that fights the horizontal scroll this table gets on narrow screens). */
  border-collapse: separate;
  border-spacing: 10px;
}
.tiers th,
.tiers td { padding: 0; }
/* Rowhead column: 140 of the 1088 sheet. The width has to sit on the FIRST
   row's cell — the empty corner in <thead> — because `table-layout: fixed`
   takes every column width from row one and ignores anything set further
   down. Setting it on the tbody <th> alone silently did nothing and left all
   five columns equal. */
.tiers thead td,
.tiers tbody th[scope="row"] { width: 12.9%; }

/* --- column heads --- */
.tiers thead th {
  padding-bottom: 12px;
  font-size: 16px;
  font-weight: 500;
  text-align: center;
}
.tiers__tier {
  display: block;
  color: var(--c-ink);          /* was a fixed #6E6E73 in the export */
  margin-bottom: 6px;
}
/* the rarity meter: four 16x3 bars, the first N lit — the same ladder the
   plate tint below runs, so the cue is stated twice and survives either one
   being missed */
.tiers__meter {
  display: flex;
  gap: 3px;
  justify-content: center;
}
.tiers__meter i {
  width: 16px;
  height: 3px;
  border-radius: 1.5px;
  background: color-mix(in srgb, var(--c-ink) 12%, transparent);
}
.tiers__meter[data-lit="1"] i:nth-child(-n + 1),
.tiers__meter[data-lit="2"] i:nth-child(-n + 2),
.tiers__meter[data-lit="3"] i:nth-child(-n + 3),
.tiers__meter[data-lit="4"] i:nth-child(-n + 4) { background: var(--c-ink); }

/* --- row heads --- */
.tiers tbody th {
  font-size: 16px;
  font-weight: 400;
  text-align: left;
  color: var(--c-ink);
}
.tiers__dot {                   /* 7px dot, 9px clear of the word, per Figma */
  display: inline-block;
  width: 7px;
  height: 7px;
  margin-right: 9px;
  border-radius: 50%;
  background: var(--world);
  vertical-align: middle;
}

/* --- the cell plate: the rarity cue, in the page's own colour.
   The export ran 2 / 3.1 / 4.3 / 5.5% BLACK. Mixing that ladder out of --c-ink
   instead fixed dark mode but left light mode NEUTRAL GREY, and unavoidably
   so: on a light page --surface-raised is plain white for all three shades, so
   the card is white and near-black ink over white can only ever make grey.

   The hue lives in the shade tokens — those are what the shade picker moves.
   So the plate is one of them at a rising alpha: the cell reads as another
   layer of the page's own colour, and it picks up violet / azure on light and
   indigo / noir on dark without naming any of them.

   High alpha, small lightness delta: the reverse of the ink version, and the
   reason the hue survives. At 3-9% any colour resolves to near-neutral.

   WHICH token differs by theme, and it has to, because the plate must always
   get LIGHTER as rarity rises:
     light — the card is --surface-raised (plain white), so the deeper colour
             is --surface-page underneath it. Mixing that darkens.
     dark  — the order inverts: --surface-page is the DARKER of the pair, so
             mixing it would sink the Epic cells into the background. The plate
             takes --surface-raised instead, which sits above the card's own
             55% blend and therefore lifts. --- */
.tiers tbody td {
  --plate: var(--surface-page);
  /* 30 at Base to a full 100 at the rarest — 30 / 53.3 / 76.7 / 100. The step
     is 70/3 rather than a round number because the two ENDS are what was
     asked for; spacing the four evenly between them is what fixes the step. */
  --tint: calc(30 + var(--t) * 70 / 3);
  border-radius: 18px;   /* only honoured because the table is `separate` */
  background: color-mix(in srgb, var(--plate) calc(var(--tint) * 1%), transparent);
}
/* Both themes ramp the SAME token — --surface-page, the page's own background
   colour — so a slot is the page deepening through the card as rarity rises,
   and "100%" means literally the theme background. There is no dark override
   at all: the rule above already reads --surface-page, and the shade picker
   swaps what that resolves to (violet / azure on light, indigo / noir on
   dark). --- */

/* --- the artwork, windowed out of the one sheet.
   Percentage background-position is what keeps this responsive: for a window
   of --win-w inside a --sheet-w sheet, the browser resolves X% against
   (boxWidth - imageWidth), so the offset works out to the window's own x at
   whatever size the cell lands on. No fixed pixel scale anywhere. --- */
.tiers__art {
  display: block;
  width: 100%;
  aspect-ratio: var(--win-w) / var(--win-h);
  background-image: url(../../assets/case/yumoji/Table.svg);
  background-repeat: no-repeat;
  background-size: calc(var(--sheet-w) / var(--win-w) * 100%) auto;
  background-position:
    calc((var(--col-0) + var(--t) * var(--col-step) + var(--win-x))
         / (var(--sheet-w) - var(--win-w)) * 100%)
    calc((var(--row-0) + var(--w) * var(--row-step) + var(--win-y))
         / (var(--sheet-h) - var(--win-h)) * 100%);
}

/* A 4x4 matrix of figures does not usefully squeeze: at 375px the cells came
   out 45px wide, which is smaller than the leaderboard icon the whole case
   study is arguing about. It scrolls sideways inside its own box instead —
   the page itself never does. */
.case__matrix-table {
  overflow-x: auto;
  overscroll-behavior-x: contain;
}
@media (max-width: 900px) {
  .tiers { min-width: 620px; }

  /* --- the scroll window reaches the card's inner edge ---
     Below this width the table is wider than the card and scrolls. The figure
     sat inside the card's padding, so the clip happened one padding-width IN
     from the card's edge: cells were sliced mid-avatar with a band of card
     surface still showing beyond the cut, which reads as broken rather than as
     content continuing. Cancelling the padding puts the cut ON the card's edge,
     where it reads as the row running under the card's own boundary.

     .case__matrix carries `overflow: hidden` so the card's 12px radius clips
     the table at the corners — without it the cells would square off outside
     the rounded edge. The card keeps its padding for the copy and legend,
     which should stay inset.

     `.case` for weight — this is a <figure>, and `.case figure { margin: 0 }`
     at (0,1,1) beats a bare `.case__matrix-table` at (0,1,0). */
  .case .case__matrix-table {
    margin-inline: calc(var(--matrix-pad) * -1);
    /* ...and put the space back INSIDE the scroller. The negative margin above
       moves the scroll WINDOW out to the card's edge; this padding sits within
       the scrollable content, so the grid starts and ends clear of that edge
       instead of butting against it. The two do different jobs and are not a
       wash: at rest the first world label lines up with the copy above it, and
       once you scroll, the cut still happens on the card's edge rather than
       one padding-width inside it. */
    padding-inline: var(--matrix-pad);
    scroll-padding-inline: var(--matrix-pad);
  }
  /* 12.9% of 620 is 80px, four short of what "Mountain" plus its dot needs,
     so the longest world name wrapped under its own bullet. Fixed px here
     rather than a bigger percentage: the word does not scale with the table. */
  .tiers thead td,
  .tiers tbody th[scope="row"] { width: 104px; }
}

/* screen-reader-only, for the table's caption */
.case__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.case__legend {
  display: flex;
  flex-wrap: wrap;
  /* both axes, because the mobile block below turns this column: centring on
     `justify-content` alone would then centre it vertically and leave the
     three lines ragged against the left edge */
  justify-content: center;
  align-items: center;
  gap: 32px;
  font-size: 14px;
  color: var(--c-ink-soft);
}

/* 11: quotes */
/* Two columns, not three. There are four quotations, and three tracks leave
   the fourth alone on a row at a third of the width — the same orphan the
   grid elsewhere on the site is written to avoid. Two gives a 2x2 block with
   no odd card, and at the case's 1040 content width each card is ~508px,
   which reads better for the long 2022 quotation than a 1/3 track did. */
.case__quotes {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 24px;
}
.case__quote {
  background: var(--c-surface-veil);
  border: 1px solid var(--c-line);
  border-radius: 12px;
  padding: 32px;
  margin: 0;
}
.case__quote p { font-size: 18px; line-height: 1.5; color: var(--c-ink); }
.case__quote figcaption { margin-top: 16px; }
/* The source line of a quotation that has a public URL behind it.
   reset.css strips colour and underline off every <a>, so a link sitting in
   body copy is invisible until it says otherwise. This is the treatment the
   About panel's contact links already carry — full --c-ink against the
   caption's softer ink, underlined, and the ↗ that marks a link leaving the
   page site-wide — so an outbound link reads the same wherever it appears. */
.case__quote-src {
  color: var(--c-ink);
  text-decoration: underline;
  text-underline-offset: 3px;
  opacity: .8;
  transition: opacity var(--motion-hover);
  /* "Capterra, 2022 ↗" is one label and one target. Left to wrap it split
     across two lines at the comma in the first card, cutting the underline
     and the tap target in half. It moves to the next line whole instead —
     it is ~105px at 14px, so it fits any card the grid produces. */
  white-space: nowrap;
}
.case__quote-src:hover { opacity: 1; }

/* 11: text 722 + stat 446 (v1 had these the other way round) */
.case__result {
  display: grid;
  grid-template-columns: 722px 446px;
  gap: 32px;
  justify-content: space-between;
  align-items: start;
}

/* --- prototype: it lives in the hero's right column now.
   The mock puts a 290x566 rounded placeholder there, tilted 4.29deg — so
   the phone takes that as its RESTING angle, composed into the live tilt
   via --rz rather than fighting it. --- */
.case__hero-proto {
  --screen-w: 268px;        /* 290 placeholder − 2×11 bezel */
  --rz: 4.29deg;
  padding: 0;
}
.case__proto-note {
  margin-top: 12px;
  font-size: 14px;
  line-height: 1.5;
  color: var(--c-ink-soft);
  text-align: center;
}

/* .case__foot is gone with the element it styled — the closing footer was a
   rule plus a second "All work" link, and the topbar's back link is sticky, so
   it duplicated a control the reader already has on screen. Its run-out space
   lives on .case's padding-bottom now (see the top of this file). */

/* ===== Adaptive ===== */
/* Below the mock's own 1440px board. The old breakpoint was 1200px, which
   left the whole 1201–1439 band — i.e. a 1280 or 1366 laptop, the commonest
   desktop widths there are — running the full-width layout it cannot fit:
   .case__hero's 802 + 332 and .case__result's 722 + 446 are fixed tracks and
   overflowed. Below 1440 they hand their flexible side a 1fr instead.
   `--case-pad` doubles as the side gutter AND feeds .case__wrap's max-width,
   so dropping it to 64 is what widens the content from 1040 to 1152 here. */
@media (max-width: 1439.98px) {
  .case {
    --case-pad: 64px;
    --case-gutter: 48px;
  }
  .case__hero   { grid-template-columns: minmax(0, 1fr) 332px; }
  .case__result { grid-template-columns: minmax(0, 1fr) 380px; }
}

/* The brief's breakpoint: h1 32, h2 22, body 16, side padding 20. */
@media (max-width: 768px) {
  .case { --case-pad: 20px; --case-gap: 56px; }

  .case__title         { font-size: 32px; }
  .case__section-title { font-size: 22px; }
  .case__subtitle,
  .case__tl h3,
  .case__row-item dt   { font-size: 22px; }
  .case__lede          { font-size: 18px; }
  .case__body p,
  .case p,
  .case__row-item dd,
  .case__stat dd,
  .case__quote p       { font-size: 16px; }
  .case__stat dt       { font-size: 32px; }
  /* description drops to 16 here, so the title steps down with it and keeps
     the same gap. Same three-class weight as the base rule, for the same
     reason — the 16px rule above is `.case p`. */
  .case__grid .case__card .case__subtitle { font-size: 19px; }

  /* every two-column block collapses; the flip has to be undone too, or the
     columns would swap and the reading order would go backwards */
  .case__row,
  .case__hero,
  .case__result,
  .case__tl            { grid-template-columns: minmax(0, 1fr); gap: 24px; }
  .case__tl > *:first-child,
  .case__tl > *:nth-child(2),
  .case__tl--flip > *:first-child,
  .case__tl--flip > *:nth-child(2),
  .case__tl--right > *:only-child { grid-column: 1; grid-row: auto; }

  /* 02: the photo moves INTO the copy — heading, first paragraph, photo, then
     "I joined…". Stacked, a picture sitting above the section title pushed the
     whole of Context below the fold for no gain.
     `display: contents` because the photo and the paragraphs are not siblings:
     the paragraphs live two levels down, in .case__col > .case__body, and
     `order` can only reorder items of the same formatting context. Dropping
     those two boxes leaves the row with four items — figure, h2, p, p — which
     is exactly what needs sequencing. Both are plain <div>s carrying no
     semantics, so nothing is lost from the accessibility tree by removing
     their boxes. */
  .case__row--figure-mid > .case__col,
  .case__row--figure-mid .case__body            { display: contents; }
  .case__row--figure-mid .case__section-title   { order: 1; }
  .case__row--figure-mid .case__body > p:first-child   { order: 2; }
  .case__row--figure-mid > figure               { order: 3; }
  .case__row--figure-mid .case__body > p:first-child ~ p { order: 4; }
  /* the row's own 24px gap spaces all four now; --figure-mid's paragraphs are
     grid items, so .case__body's 20px rhythm would stack on top of it */
  .case__row--figure-mid .case__body > * + *    { margin-top: 0; }

  /* Both of these only mean anything while there are two columns. Stacked,
     right-aligned copy would rag against the rail it no longer sits beside,
     and "hang from the bottom of the row" has no row to hang from. */
  /* the nudge is gated to >=1024 now, so there is nothing to undo here */
  .case__tl > .case__tl-text { text-align: left; }
  .case__self-end            { align-self: start; }

  .case__grid,
  .case__quotes        { grid-template-columns: 1fr; gap: 16px; }

  /* 09: the four stat cards go two-up. The base rule is
     `auto-fit, minmax(180px, 1fr)`, and two 180px tracks plus the gap need
     384px — four more than a 375px screen has, so they collapsed to a single
     column and the section became four tall cards in a row you had to scroll
     past. Two fixed columns fit at 335px with room to spare, and a number
     beside its label is a small enough unit to halve. Padding comes down with
     them so the cards stay square-ish rather than becoming tall boxes. */
  .case__stats         { grid-template-columns: repeat(2, 1fr); gap: 12px; }
  .case__stat          { padding: 20px; }

  /* --- Both carousels run FULL-BLEED ---
     A scroll container sitting inside .case__wrap stops at the page's 20px
     side padding, so a card scrolling past was clipped 20px short of the
     screen and the strip read as a boxed-in window rather than as content
     running off the edge. Cancelling that padding with a negative margin and
     re-adding it as the container's OWN padding gives both: the scroll port
     spans the whole viewport, while the first item still starts on the page's
     text margin. scroll-padding keeps snapping to that same margin.

     `.case` carried on both selectors for WEIGHT, and it is load-bearing for
     the strip: .case__result-strip is a <figure>, so the page-wide
     `.case figure { margin: 0 }` at (0,1,1) out-weighs a bare
     `.case__result-strip` at (0,1,0) and silently zeroed the negative margin —
     the padding applied, the bleed did not, and the strip just sat 20px
     further in. The photos row is a <div> and never hit it, which is exactly
     what made the bug look like it only affected one of the two. */
  .case .case__photos,
  .case .case__result-strip {
    margin-inline: calc(var(--case-pad) * -1);
    overflow-x: auto;
    overscroll-behavior-x: contain;   /* don't chain into the page's scroll */
  }
  /* The avatar strip keeps the page's own text margin as its inner padding, so
     the first figure lines up with the copy above it and the row simply runs
     off the right edge. */
  .case .case__result-strip {
    padding-inline: var(--case-pad);
    scroll-padding-inline: var(--case-pad);
  }

  /* 03: the three phone shots become a carousel.
     Stacked, they made the reader scroll past three near-identical screens to
     get on with the section. Side by side they are a set, which is the point —
     the same avatar at three sizes.

     CENTRED, with both neighbours broken by the screen edges — the card in
     hand is whole and the ones either side are visibly cut, which is what says
     the row continues in both directions.

     The geometry falls out of symmetric padding: the container is full-bleed,
     so its border box IS the viewport, and `flex: 0 0 100%` resolves against
     the CONTENT box — the viewport less the two paddings. A card that wide sits
     exactly between them, so it is centred by construction at any screen size,
     with nothing to keep in sync. --peek is then literally what is left of each
     neighbour, the gap having been added to the padding to account for it.

     Do NOT be tempted to set the card width as its own percentage: a
     percentage flex-basis resolves against the content box while percentage
     padding resolves against the padding box, so two values derived from one
     number disagree and the card lands off-centre (measured: 137px wide where
     214 was intended).

     Snapping is right HERE and wrong for the avatar strip below: these are
     three discrete figures with obvious resting points, whereas the avatars
     are one SVG where a snap would land mid-figure. */
  .case .case__photos {
    /* Card width taken from the Figma mobile layout: a 402pt frame with the
       centred shot at 173pt, i.e. 43% of the screen, leaving ~94pt of each
       neighbour on show. Sized in vw rather than a percentage BECAUSE the
       container is full-bleed — its border box is exactly the viewport, so
       100vw and the container agree, whereas a percentage here would resolve
       against .case__wrap's narrower content box and come out short. */
    --card: 43vw;
    --gap: 20px;
    display: flex;
    grid-template-columns: none;   /* drop the desktop 325fr/330fr/336fr tracks */
    gap: var(--gap);
    scroll-snap-type: x mandatory;
    /* Just the page margin. It was (100vw - card)/2 — the ~114px each side
       that lets the FIRST and LAST cards pull themselves to the middle — but
       that padding is scrollable, so both ends of the swipe were a screenful
       of nothing. Snapping still centres the middle card, because a snap
       position outside the scroll range is clamped to the nearest end: card 1
       rests flush left, card 3 flush right, and only card 2 actually centres.
       That is the whole travel, with no empty run at either end. */
    padding-inline: var(--case-pad);
    scroll-padding-inline: var(--case-pad);
  }
  .case__photos figure {
    flex: 0 0 var(--card);         /* fixed width now — the padding no longer
                                      defines it, so it cannot come from 100% */
    scroll-snap-align: center;
  }
  /* One shared box so the cards don't finish at ragged heights: the three
     sources are all 673 tall but 325/330/336 wide, so at equal widths their
     heights would differ by ~14px. `cover` trims under 2% off the widest —
     invisible on a phone screenshot, and it buys three identical cards.
     `.case .case__photos` for weight: `.case figure img` is (0,1,2). */
  .case .case__photos img {
    aspect-ratio: 330 / 673;
    object-fit: cover;
  }
  .case__row-item      { grid-template-columns: 1fr; gap: 8px; }
  .case__legend        { flex-direction: column; gap: 8px; }

  .case__card          { padding: 24px; }
  /* keeps the table's negative margin in step with the smaller card padding */
  .case__matrix        { --matrix-pad: 24px; }

  /* The Worlds x Tiers card is deliberately NOT full-bleed. It was briefly —
     card edge-to-edge, radius and side borders dropped, table spanning the
     screen — which recovered 90px of scroll width but turned a bordered card
     into a band and was reverted on request. It keeps its inset, its radius
     and its own padding; the table simply scrolls inside it. */

  /* 11: the seven finished avatars become a sideways-scrolling strip.
     Fitted to the column they were ~48px wide and ~110px tall — smaller than
     the leaderboard icon this whole case study argues about, and the faces,
     hair and dress that ARE the evidence stopped being legible. So the height
     is set and the width follows, which overflows the column and scrolls,
     exactly the way the Worlds x Tiers table already handles being too wide
     for a phone.

     It is ONE SVG of seven figures, not seven elements, so there is nothing to
     snap to — scroll-snap would land on arbitrary points mid-figure. A plain
     scroll is the honest control here.

     `.case .case__result-strip img` for weight: the base rule is
     `.case figure img` (0,1,2), so a bare `.case__result-strip img` (0,1,1)
     loses to it and silently does nothing — the same trap `.case__photos img`
     hit. */
  /* overflow + the full-bleed margins are set in the shared rule above */
  .case .case__result-strip img {
    --strip-h: min(56vh, 360px);      /* tall, but never half a short screen */
    width: auto;
    max-width: none;                  /* or the reset's 100% cap defeats the height */
    height: var(--strip-h);
    /* Pull the SVG's OWN blank margins off both ends. Rasterised and scanned,
       the artwork starts at x=56 and ends at x=1142 inside a 1200-wide canvas —
       so ~4.7% of dead space at each end. Left in, the first avatar began 50px
       inside the page margin (with the row already scrolled to 0) and the third
       was reduced to a sliver, which read as a misaligned strip rather than a
       row that continues.
         56/1200 of the width, and width = height x 3 (the file is 1200x400),
         so the offset is height x 3 x 56/1200 = height x 0.14.
       Expressed off --strip-h so it tracks the height at any screen size. */
    margin-inline: calc(var(--strip-h) * -0.14);
  }

  .case__hero          { padding-top: 32px; }
  .case__section       { padding-block: 28px; }
  .case__section > * + * { margin-top: 20px; }

  /* The rail moves to the left edge and entries stop alternating. The entry
     also gets smaller, so the dot moves with it — and the rail's opening ramp
     follows on its own, since the base rule computes it from exactly the two
     numbers overridden here. */
  .case__timeline {
    --tl-pad: 16px;
    --tl-head-mid: 15.4px;      /* half the stacked h3's line box: 22px x 1.4 */
    --case-dot-size: 9px;
  }
  .case__timeline::before { left: 7px; }
  .case__tl            { padding-left: 32px; }
  /* Only `left` here. The size carries its own centring, and the base rule
     already reads --tl-pad + --tl-head-mid — both overridden above — so the
     dot lands on the middle of the year itself. It used to be a flat 24px,
     which sat it ~6px high of the heading. */
  .case__tl::before    { left: 7px; }

  .case__hero-proto    { --screen-w: min(300px, calc(100vw - 72px)); --rz: 0deg; }
}

/* The boxes are opaque enough to sit ON the page rather than in it, but at
   --c-surface-veil (82%) the dot grid still came through as faint specks.
   A blur on the backdrop smears those specks into a flat wash, so the surface
   reads as one clean tint instead of a screen door — no sheen, no highlight,
   nothing else: the blur is doing one job.

   Guarded: without backdrop-filter the boxes are simply the veil tint, which
   is where they started and a finished look on its own. */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .case__card,
  .case__stat,
  .case__quote {
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
  }
}
