/* ==========================================================================
   Lydia Ong — Product Design Portfolio
   Design tokens: Archivo (body) + Space Grotesk (display)
   Neutrals in black/white, yellow #FFFF00 as hero accent (from her logo),
   green/teal as secondary accents pulled from the same logo mark.
   ========================================================================== */

:root {
  /* ---- Brand palette (sampled from _lydia-logo.png) ---- */
  --yellow: #FFFF00;
  --green: #4FC874;
  --teal: #17FFBD;

  /* ---- Neutrals: a bone ramp, warmed toward the yellow's own hue family so
     the accent reads as chosen rather than applied. The cool zinc ramp it
     replaces put yellow in the room as a stranger and flattened the
     black-and-white photography against a grey close to its own midtones.

     Contrast on the bone page (#FCFAF5): ink 17.0:1, ink-soft 14.3:1,
     grey-600 7.3:1 — all clear of AA. Yellow is untouched and still cannot
     carry text on a light ground (1.04:1), so the highlighter-behind-ink
     pattern stands. Tint strength, measured as the red-to-blue spread, is 11 against the
     original bone's 13 — so the warmth is nearly back while the page stays
     at 97% lightness rather than the original 94%. Panels are white and sit
     1.04 from the page, so the work cards still separate by their shadow
     and hairline rather than by tone. ---- */
  --ink: #1A1813;
  /* Not for text. It measured 1.19:1 against --ink — a difference nothing on
     screen could show — so every paragraph that used it now takes --ink, and
     body copy runs on two shades: --ink for prose, --grey-600 for the lines
     that support it. This stays because --border-dark is built from it. */
  --ink-soft: #2A271F;
  --paper: #FAFAFA;
  --paper-alt: #FFFFFF;
  --white: #FFFFFF;
  /* Secondary body copy — supporting lines, notes, captions, labels. Darkened
     a step from #57534A: it measured 7.34:1 on paper, and at body size the
     long detail paragraphs read noticeably lighter than the copy above them.
     Now 8.99:1, still clearly a step below --ink's 16.99:1. */
  --grey-600: #4A463E;
  --grey-400: #A29C92;
  --border-light: #E4E4E7;
  --border-dark: #2A271F;

  /* ---- Semantic ---- */
  --bg: var(--paper);
  --fg: var(--ink);
  --bg-inverse: var(--ink);
  --fg-inverse: var(--paper);
  --accent: var(--yellow);
  --accent-ink: var(--ink);
  /* Every eyebrow, label and client line on the site — one role, not one
     colour. Inverted grounds redeclare it below rather than overriding the
     colour of each element that happens to sit on them.
     A step lighter than --grey-600's 8.99:1: eyebrows are short, tracked-out
     and uppercase, so they carry at a lower weight than the long detail
     paragraphs that grey was tuned for.
     One value, every ground — no light/dark variants. Chosen as the best a
     single colour can do across all five grounds the site uses: 4.15:1 on
     paper, 4.33:1 on the white cards, 4.1:1 on ink, 4.39:1 on night and
     4.12:1 on navy. Marginally under the 4.5 floor for 12.8px uppercase, but
     evenly so rather than failing badly on the dark half. */
  --eyebrow-ink: #7E796F;
  /* Standalone hairline rules — the hero's divider and the footer's top edge.
     --border-light (1.22:1) is tuned for card edges, which also carry a fill
     and a shadow to describe them; a bare 1px line has only its own contrast
     to work with. On a Mac that line is two device pixels and reads fine, but
     at the 1x scaling most Windows displays run it is a single pixel and all
     but disappears. 1.62:1 — still a hairline, not a border. */
  --rule-hairline: #CBC7BE;

  /* Half of the seam between any two sections. */
  --section-gap: var(--space-6);

  /* ---- Type: one bold family, unified across the whole site ---- */
  --font-display: 'Bricolage Grotesque', 'Helvetica Neue', Arial, sans-serif;
  --font-body: 'Bricolage Grotesque', 'Helvetica Neue', Arial, sans-serif;

  /* Fluid type scale */
  /* Display: the two headline moments on the site — the hero statement and the
     closing invitation. Nothing else is allowed to reach this size. */
  --fs-display: clamp(3.2rem, 5.1vw, 4.9rem);
  --fs-h1: clamp(2.2rem, 2.2vw + 1.3rem, 3.6rem);
  --fs-h2: clamp(1.9rem, 2vw + 1.2rem, 3rem);
  /* Headings at this size were inheriting the body's 1.55, which opens a gap
     between lines wider than the type is tall. 20% tighter. */
  --lh-h2: 1.24;
  --fs-h3: clamp(1.35rem, 1vw + 1rem, 1.9rem);
  /* Deliberately below --fs-h2: the section heading ("Selected works") is
     the parent of this list, so the entries must not out-shout it. */
  --fs-work-title: clamp(1.65rem, 1.9vw + 0.95rem, 3rem);
  --fs-lead: clamp(1.15rem, 0.6vw + 1rem, 1.5rem);
  --fs-body: clamp(1rem, 0.2vw + 0.94rem, 1.125rem);
  /* Body copy comes in two weights. The size token says how big the text is;
     these say which of the two it is, so a block can be moved between them
     without a bare number being retyped at the call site. */
  /* Weights. Four steps, nothing between them. */
  --fw-regular: 400;
  --fw-medium: 500;
  --fw-semibold: 600;
  --fw-bold: 700;
  /* Body copy's two weights, named by role rather than by number. */
  --fw-body: var(--fw-regular);
  --fw-body-bold: var(--fw-semibold);

  /* Line heights, tightest first. Headings run tight because their own size
     supplies the breathing room; running prose needs the leading. */
  --lh-flat: 1;
  --lh-hero: 0.86;
  --lh-display: 1.02;
  --lh-h2: 1.24;
  --lh-tight: 1.15;
  --lh-snug: 1.3;
  /* Short statements set in body copy — a numbered point and the line beneath
     it — take a tighter leading than running prose, but the same one as each
     other: a bold line at 1.3 beside a regular line at 1.5 read as two
     different sizes even though both measure 17px. */
  --lh-body-tight: 1.45;
  --lh-body: 1.55;
  --lh-loose: 1.6;

  /* Tracking. Display type is tightened as it grows; labels are opened up. */
  --ls-display: -0.03em;
  --ls-heading: -0.02em;
  --ls-tight: -0.01em;
  --ls-label: 0.14em;
  --ls-label-tight: 0.06em;
  --ls-wide: 0.26em;
  --fs-small: 0.875rem;
  --fs-label: 0.8rem;
  /* Micro: the one step below a label — client lists and similar fine print. */
  --fs-micro: 0.72rem;
  /* Outlined list numerals — a graphic marker rather than running type, so it
     holds one fixed size instead of scaling with the headings. */

  /* Spacing (spacious / poster feel) */
  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2rem;
  --space-5: 3rem;
  --space-6: 4.5rem;
  --space-7: 6rem;
  --space-8: 9rem;
  --space-9: 12rem;

  /* The space between two points in any list on the site — the numbered
     points, the supporting bullets under one of them, and the outcomes in an
     impact band. Three mechanisms used to set this independently (a grid gap,
     a margin in em, and vertical padding), so the same list read looser in
     one section than another. */
  --list-gap: var(--space-2);

  --header-h: 84px;
  --container: 1296px;
  /* The case-study content band: the same column the homepage uses. The rail
     takes 14% of it, leaving the rest to the copy and the gutter between them.
     It was 18%: the rail holds seven or eight short section names, so the extra
     width bought nothing it needed and pushed the content further off centre
     than the page could carry. */
  --cs-band: calc(min(var(--container), 100vw) - 2 * var(--gutter));
  --cs-rail-col: calc(0.14 * var(--cs-band));
  /* A 1296px shell whose inner padding tightens to 15px once there is room, so
     the content line is 1266px wide, and that falls back to a proportional
     gutter on narrower screens. The shell is a max-width, so narrower viewports
     are unchanged by it either way. */
  --gutter: clamp(1.25rem, 4vw, 4rem);
  /* The container's own inner padding. Anything aligning to the content edge
     (the section rail) reads this rather than guessing at --gutter. */
  --container-pad: var(--gutter);

  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --dur-fast: 220ms;
  --dur-mid: 480ms;
  --dur-slow: 760ms;
}

/* ==========================================================================
   Reset
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

html, body { margin: 0; padding: 0; }

body {
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-body);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}

img, svg, video { display: block; max-width: 100%; height: auto; }

a { color: inherit; text-decoration: none; }

button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }

ul, ol { margin: 0; padding: 0; list-style: none; }

h1, h2, h3, h4, p, figure, dl, dd { margin: 0; }

html {
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-h) + 12px);
}

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

::selection { background: var(--yellow); color: var(--ink); }

:focus-visible {
  outline: 3px solid var(--yellow);
  outline-offset: 3px;
}

.container {
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--container-pad);
  position: relative;
  z-index: 2;
}
@media (min-width: 1240px) {
  :root { --container-pad: 15px; }
}

/* Every section carries the same block padding, so every seam between two of
   them is exactly twice it — 144px on desktop, 96px below 720. One token, no
   per-section overrides: the previous set had drifted to 72 / 144 / 144 /
   240 / 288 as sections were added and tuned one at a time.

   Two deliberate exceptions remain, both documented where they are set: the
   hero-to-work seam (the hero has its own stage padding) and the contact
   section on narrow screens (its top padding is driven by the hand image). */
.section {
  padding-block: var(--section-gap);
}
/* Exception 3: the swimlane already carries its own bottom padding for the
   card shadows, and the arrows sit below that, so the section's own closing
   stack read as a second gap on top of a gap. */
#testimonials { padding-bottom: 0; }
/* Exception 4: the closing section sits straight on the footer, so it needs
   far less below its content than the seams between sections carry. */
#contact { padding-bottom: var(--space-5); }

/* Its own opaque ground and stacking context. Kept after the petal layer was
   removed: the section still needs to sit above the hero's fixed background
   rather than let it show through. */
.section--occludes {
  position: relative;
  z-index: 2;
  background: var(--paper);
}

@media (max-width: 720px) {
  :root { --section-gap: var(--space-5); }
}

.eyebrow,
/* The case-study hero's meta labels. They were a hand-copy of this block with
   their own raw grey, which is how they drifted off the token — now they are
   the same definition, not a near-copy of it. */
.cs-meta dt {
  font-family: var(--font-body);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--eyebrow-ink);
}
/* Three glyph points that belong to one statement, set across a single row so
   they read as one set rather than a 2 + 1 grid with a stranded last item.
   Desktop only: below 1025px the columns are too narrow to carry a heading of
   this length, so it falls back to the two-up grid. */
/* The glyph sits above its point rather than beside it, at every width. Beside
   the text it takes 56px of the column in a gutter, which at three-up cost two
   extra lines per heading and on a phone pushed the measure in for no gain.
   Each selector carries both classes so it outranks the .cs-numbered--glyphs
   defaults further down the file regardless of source order. */
.cs-numbered--glyphs-row.cs-numbered--glyphs .cs-numbered__item {
  grid-template-columns: 1fr;
}
.cs-numbered--glyphs-row.cs-numbered--glyphs .cs-numbered__item > p { grid-column: 1; }
.cs-numbered--glyphs-row.cs-numbered--glyphs .cs-numbered__glyph {
  grid-column: 1;
  grid-row: auto;
  margin-top: 0;
  margin-bottom: var(--space-2);
}
/* The trio's 80%/92% measures assume a half-width column with a gutter; with
   the glyph stacked the text takes the whole column it has. */
body:has(.cs-hero) .cs-numbered--glyphs-row.cs-numbered--trio .cs-numbered__item > p:not(.cs-numbered__detail):not(.cs-numbered__owner),
.cs-numbered--glyphs-row.cs-numbered--trio .cs-numbered__item > p:not(.cs-numbered__detail):not(.cs-numbered__owner) { max-width: none; }

/* Three across on tablet and up. Below this it is one column on a phone — the
   stacking above holds at every width. */
@media (min-width: 701px) {
  .cs-numbered.cs-numbered--glyphs.cs-numbered--glyphs-row {
    grid-template-columns: repeat(3, 1fr);
    column-gap: var(--space-4);
    /* Wider than the column gap: each item is a tall block, and 32px between
       rows read as one continuous column rather than two rows of three. */
    row-gap: var(--space-5);
  }
}

/* Stacked on a phone, each glyph point is a tall block ending in a tag, and
   the list's 16px gap left the next heading sitting directly under it. The
   items need more between them than the lines inside them have. */
@media (max-width: 700px) {
  .cs-numbered.cs-numbered--glyphs { row-gap: var(--space-4); }
}

/* Eyebrows hold one value on every ground, light or inverted — the dark
   sections and the impact block no longer re-tune it. */

/* A greeting reads as a sentence, not a tracked-out label */
.eyebrow--sentence {
  text-transform: none;
  letter-spacing: 0;
  font-weight: var(--fw-medium);
  font-size: var(--fs-body);
}

/* ==========================================================================
   Custom cursor (desktop, fine pointer only)
   ========================================================================== */

.cursor-trail {
  position: fixed;
  top: 0; left: 0;
  width: 0; height: 0;
  pointer-events: none;
  z-index: 999;
  opacity: 0;
  transition: opacity var(--dur-fast);
}
.cursor-trail__dot {
  position: fixed;
  top: 0; left: 0;
  width: calc(var(--size, 8px) * var(--scale, 1));
  height: calc(var(--size, 8px) * var(--scale, 1));
  margin: 0;
  border-radius: 50%;
  /* Solid in the middle, dissolving at the rim — a soft-edged mark rather than
     a hard disc. The gradient does the work so no filter is needed. */
  background: radial-gradient(closest-side,
    var(--yellow) 0%,
    var(--yellow) 46%,
    rgba(255, 255, 0, 0.55) 72%,
    rgba(255, 255, 0, 0) 100%);
  opacity: calc(var(--alpha, 1) * var(--fade, 1));
  will-change: transform, opacity;
  transition: width var(--dur-fast) var(--ease-out), height var(--dur-fast) var(--ease-out);
}
/* Over a link the head of the trail opens up. */
body.cursor-hover .cursor-trail__dot {
  width: calc(var(--size, 8px) * var(--scale, 1) * 1.6);
  height: calc(var(--size, 8px) * var(--scale, 1) * 1.6);
}

@media (hover: none), (pointer: coarse) {
  .cursor-trail { display: none; }
}

/* ==========================================================================
   Header — always visible: logo, name, nav, contact
   ========================================================================== */

.site-header {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 100;
  height: var(--header-h);
  display: flex;
  align-items: center;
  /* Glass, in two states. Three things make it read as a material rather than
     a tinted box: a low tint alpha so what passes underneath stays legible, a
     heavy blur with saturation pushed past 1 so colour blooms through instead
     of going grey, and a one-pixel inner highlight along the top edge — the
     lit lip that gives the panel thickness.

     WHITE GLASS is the default, because most of the site is light. The bar
     flips to dark glass only while a dark section is behind it (see
     initHeaderTheme). Everything inside inherits the header's colour, so the
     nav, brand and underline flip with it rather than being restated. */
  background: rgba(251, 251, 248, 0.58);
  backdrop-filter: blur(24px) saturate(180%);
  -webkit-backdrop-filter: blur(24px) saturate(180%);
  border-bottom: 1px solid rgba(10, 10, 10, 0.08);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.65),
    0 1px 28px rgba(0, 0, 0, 0.06);
  color: var(--ink);
  transition: transform var(--dur-mid) var(--ease-out), background var(--dur-mid), border-color var(--dur-mid);
}

/* Dark glass, while a dark section sits behind the bar. */
.site-header--on-dark {
  background: rgba(24, 24, 27, 0.55);
  border-bottom-color: rgba(255, 255, 255, 0.09);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.10),
    0 1px 28px rgba(0, 0, 0, 0.30);
  color: var(--paper);
}

/* One mark or the other, never both. */
.brand__mark--on-dark { display: none; }
.site-header--on-dark .brand__mark--on-light { display: none; }
.site-header--on-dark .brand__mark--on-dark { display: block; }

/* Without backdrop-filter the tint alone would be a washed-out band with text
   floating on top of the page — so opt out of the glass and go solid. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .site-header { background: rgba(251, 251, 248, 0.96); }
  .site-header--on-dark { background: rgba(10, 10, 10, 0.94); }
  .site-header.is-at-top { background: transparent; }
}

/* At the very top the bar carries no material at all, so the nav reads as
   sitting directly on the page; the glass fades in as soon as content starts
   passing underneath. Two classes, so it wins over the dark-glass variant. */
.site-header.is-at-top {
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border-bottom-color: transparent;
  box-shadow: none;
}

.site-header.is-hidden { transform: translateY(-100%); }
/* Held off-frame for the opening beats, then slid in as the type settles.
   Declared after .is-hidden so the hold wins while the sequence is running. */
/* On white glass the CTA is the site's normal ink button. On dark glass it
   inverts to yellow — a black button on a black bar is not a button. */
.site-header--on-dark .btn--fill {
  background: var(--yellow);
  color: var(--ink);
  border-color: var(--yellow);
}
.site-header--on-dark .btn--fill:hover {
  background: var(--paper);
  color: var(--ink);
  border-color: var(--paper);
}

.site-header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  width: 100%;
}

.brand {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-body);
  letter-spacing: var(--ls-tight);
  flex-shrink: 0;
}
.brand img { width: 30px; height: 30px; }

.main-nav {
  display: flex;
  align-items: center;
  gap: var(--space-5);
}
.main-nav a {
  position: relative;
  isolation: isolate;
  font-size: var(--fs-small);
  font-weight: var(--fw-semibold);
  padding: 0.3em 0;
}
/* The underline is kept for the current section only — hover gets the same
   yellow highlighter the rest of the site's links use. */
.main-nav a::after {
  content: '';
  position: absolute;
  left: 0; right: 100%;
  bottom: -2px;
  height: 2px;
  background: currentColor;
  transition: right var(--dur-mid) var(--ease-out);
}
.main-nav a.is-active::after { right: 0; }
.main-nav a.is-active { font-weight: var(--fw-bold); }
.main-nav a::before {
  content: '';
  position: absolute;
  z-index: -1;
  left: -0.3em;
  right: -0.3em;
  top: 0.05em;
  bottom: 0.05em;
  background: var(--yellow);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--dur-mid) var(--ease-out);
}
.main-nav a:hover::before,
.main-nav a:focus-visible::before { transform: scaleX(1); }
/* Ink on the yellow whichever ground the bar is on — paper would be 1.07:1. */
.main-nav a:hover,
.main-nav a:focus-visible,
.site-header--on-dark .main-nav a:hover,
.site-header--on-dark .main-nav a:focus-visible { color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
  .main-nav a::before { transition: none; }
}

.header-contact {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  font-size: var(--fs-label);
  font-weight: var(--fw-medium);
  flex-shrink: 0;
}
.header-contact a {
  padding: 0.55em 0.9em;
  border: 1.5px solid var(--ink);
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast);
  white-space: nowrap;
}
.header-contact a:hover { background: var(--ink); color: var(--paper); }
/* The CTA is a filled button first: this sits above .header-contact a, which
   would otherwise put an ink border and a paper hover on it. */
.header-contact .btn--fill {
  border-color: var(--ink);
}
.header-contact .btn--fill:hover {
  background: var(--yellow);
  color: var(--ink);
  border-color: var(--yellow);
}

.nav-toggle {
  display: none;
  flex-direction: column;
  gap: 5px;
  padding: 8px;
  z-index: 110;
}
.nav-toggle span {
  width: 22px; height: 2px;
  background: currentColor;
  transition: transform var(--dur-fast) var(--ease-out), opacity var(--dur-fast);
}

@media (max-width: 980px) {
  .main-nav { display: none; }
  .nav-toggle { display: flex; }
}

@media (max-width: 720px) {
  :root { --header-h: 68px; }
  .brand span { display: none; }
}

/* Mobile full-screen nav overlay */
.mobile-nav {
  position: fixed;
  inset: 0;
  z-index: 105;
  background: var(--ink);
  color: var(--paper);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-3);
  padding: var(--space-5) var(--gutter);
  transform: translateY(-100%);
  transition: transform var(--dur-mid) var(--ease-in-out);
}
.mobile-nav.is-open { transform: translateY(0); }
/* The mark sits at the top of the panel, where the header's own logo was
   before the menu covered it, so the page still identifies itself. */
.mobile-nav__brand {
  position: absolute;
  top: var(--space-5);
  left: var(--gutter);
  display: block;
  line-height: 0;
}
.mobile-nav__brand img { display: block; width: 30px; height: auto; }
/* Close control mirrors the mark across the panel, where the burger was. */
.mobile-nav__close {
  position: absolute;
  top: var(--space-5);
  right: var(--gutter);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: -10px -10px 0 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--paper);
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
.mobile-nav__close:hover,
.mobile-nav__close:focus-visible { color: var(--yellow); transform: rotate(90deg); }
.mobile-nav a {
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  font-weight: var(--fw-semibold);
}
.mobile-nav a:hover { color: var(--yellow); }
.mobile-nav .header-contact {
  margin-top: var(--space-4);
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-2);
}
.mobile-nav .header-contact a { border-color: var(--paper); }
.mobile-nav .header-contact a:hover { background: var(--yellow); color: var(--ink); border-color: var(--yellow); }

/* ==========================================================================
   Touch press = hover
   Coarse pointers have no hover, so the highlighter sweeps are driven by a
   press instead. JS holds `.is-pressed` briefly past touchend so the sweep
   finishes even on a quick tap that navigates away.
   ========================================================================== */
@media (hover: none), (pointer: coarse) {
  .main-nav a.is-pressed::before { transform: scaleX(1); }
  .contact__link.is-pressed::before,
  .contact__social.is-pressed .contact__social-text::before { transform: scaleX(1); }
  .cs-next.is-pressed .cs-next__title { background-size: 100% 100%; }
  .work-card.is-pressed .work-card__title { color: var(--ink); }
  .work-card.is-pressed .work-card__title-mark { background-size: 100% 100%; }
  .cs-rail a.is-pressed .cs-rail__label { background-size: 100% 100%; color: var(--ink); }
}

/* ==========================================================================
   Testimonials — a swimlane
   A contained horizontal scroller, not a carousel: nothing auto-advances, so
   there is no motion to pause and no reading clock to beat. The lane is a
   focusable region so it can be scrolled from the keyboard, and the arrows
   give pointer users an affordance that a bare overflow does not.
   ========================================================================== */
/* The lane sits OUTSIDE .container so it runs the full width of the section —
   no 100vw breakout, which would overflow by the width of the scrollbar.
   It then pads itself back to where the container's text starts, so the first
   card lines up with the heading and the last stops at the mirrored inset.
   The cards that run off the edge are the affordance: "more this way". */
.swimlane {
  position: relative;
  --lane-inset: max(var(--container-pad),
                    calc((100% - var(--container)) / 2 + var(--container-pad)));
}
.swimlane__track {
  list-style: none;
  margin: 0;
  display: flex;
  gap: var(--space-3);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: var(--lane-inset);
  padding: 4px var(--lane-inset) var(--space-3);
  /* Firefox shows a persistent bar; the arrows and the cut card are the
     affordance here, so it is hidden in favour of them. */
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
}
.swimlane__track::-webkit-scrollbar { display: none; }
.swimlane__track:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

.testimonial {
  flex: 0 0 min(340px, 78vw);
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-4);
  background: var(--paper-alt);
  border: 1px solid var(--border-light);
  border-radius: 14px;
  box-shadow: 0 1px 2px rgba(80, 66, 40, 0.04),
              0 8px 24px -12px rgba(80, 66, 40, 0.10);
}
.testimonial__quote { margin: 0; }
.testimonial__quote p {
  margin: 0;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--ink);
}
/* The quote carries the weight; the attribution is the footnote, so it sits
   at the foot of the card however long the quote runs. */
.testimonial__cite {
  font-style: normal;
  display: grid;
  gap: 0.15em;
}
/* One drawing per card, for the quality that quote is praising — a target for
   the right problem, a flag for taking the lead, and so on. Line work in ink
   with a single yellow shape, so the six read as one family while the accent
   lands somewhere different in each. Decorative: the quote says it in words,
   so the mark is hidden from the accessibility tree. */
.testimonial__trait {
  display: block;
  /* The slack sits above the mark rather than above the citation, so the icon
     and the name travel together at the foot of every card — whatever length
     the quote above them runs to. */
  margin-top: auto;
  /* Each drawing is normalised to the same 36-unit height inside its 48-unit
     box, with its stroke compensated for the scale, so the six read as one
     size rather than six. */
  width: 67px;
  height: 67px;
  /* Half the card's own 24px gap: the mark belongs to the name beneath it,
     not to the quote above. */
  margin-bottom: calc(var(--space-3) * -0.5);
  /* Two greys: the subject of each drawing in the eyebrow's grey, the parts
     around it a step lighter. currentColor carries the darker one, so this
     rule still governs the weight of the whole set. */
  color: var(--grey-600);
}
.testimonial__cite b {
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  color: var(--ink);
}
.testimonial__cite span {
  font-size: var(--fs-small);
  color: var(--grey-600);
  /* Two lines held open whether the role wraps or not. The citation is pinned
     to the bottom of the card, so a one-line role pushed its name a line lower
     than its neighbours'; reserving the second line puts every name on the
     same baseline across the row. */
  min-height: calc(2 * var(--lh-body) * 1em);
}

/* Controls sit on the container's line, not the viewport's. */
.swimlane__controls {
  display: flex;
  gap: var(--space-1);
  margin-top: var(--space-2);
  padding-inline: var(--lane-inset);
}
.swimlane__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid var(--border-light);
  border-radius: 50%;
  background: var(--paper-alt);
  color: var(--ink);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              opacity var(--dur-fast) var(--ease-out);
}
.swimlane__btn:hover { background: var(--yellow); border-color: var(--yellow); }
/* Disabled rather than hidden: the control keeps its place, so the row does
   not reflow as you reach either end of the lane. */
.swimlane__btn[disabled] { opacity: 0.35; cursor: default; }
.swimlane__btn[disabled]:hover { background: var(--paper-alt); border-color: var(--border-light); }

@media (prefers-reduced-motion: reduce) {
  .swimlane__track { scroll-behavior: auto; }
}

/* ==========================================================================
   Reveal-on-scroll
   ========================================================================== */

[data-reveal] {
  opacity: 0;
  transform: translateY(28px);
  transition: opacity var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out);
}
[data-reveal].is-visible { opacity: 1; transform: translateY(0); }

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

/* ==========================================================================
   Page intro
   Measured off pratibhajoshi.com rather than guessed: its sections carry
   transform/opacity transitions of 0.8s ease-out, and move from
   translateY(30px) scale(0.99) at opacity 0.4 to rest. The navbar is a
   plain opacity fade with no movement.

   Two things make it read the way it does. It starts at 0.4, not 0, so the
   page arrives already legible and settles rather than materialising. And
   the hero moves as ONE block — no per-element stagger — so it reads as the
   page arriving, not as a sequence being performed.
   ========================================================================== */
:root { --dur-intro: 800ms; --ease-intro: ease-out; }

.js .site-header {
  opacity: 0;
  transition: opacity var(--dur-intro) var(--ease-intro);
}
.js .hero__content .container {
  opacity: 0.4;
  transform: translateY(30px) scale(0.99);
  transition: opacity var(--dur-intro) var(--ease-intro),
              transform var(--dur-intro) var(--ease-intro);
}
.js.is-ready .site-header { opacity: 1; }
.js.is-ready .hero__content .container { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .js .site-header,
  .js .hero__content .container { opacity: 1; transform: none; transition: none; }
}

/* ==========================================================================
   Hero
   ========================================================================== */

/* One viewport. The whole sequence runs on its own timeline now, so the hero
   needs no scroll track and no sticky stage. */
.hero {
  position: relative;
  min-height: 78dvh;
  background: var(--paper);
}
/* The hero already carries its own bottom padding, so the section that
   follows it does not need a full stack of its own on top. */
/* Exception 1: the hero ends with its own stage padding, so this seam is a
   single --section-gap rather than the doubled one between two sections. */
.hero + .section { padding-top: var(--section-gap); }

.hero__stage {
  position: relative;
  min-height: 78dvh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Left-aligned, ranged off the container's own edge — the statement, the
     notes and the figures all hang from one line down the left. */
  text-align: left;
  /* Clears the header, then drops the copy a further step down the stage. */
  padding-top: calc(var(--header-h) + var(--space-6));
  padding-bottom: var(--space-7);
  overflow: hidden;
  background: var(--paper);
}
/* Desktop sits the hero block lower in the stage: more above it, less below,
   so it is centred nearer the fold than the header. */
@media (min-width: 1061px) {
  .hero__stage {
    padding-top: calc(var(--header-h) + var(--space-8));
    padding-bottom: var(--space-4);
  }
}


.hero__kicker .dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--green);
  box-shadow: 0 0 0 4px rgba(79, 200, 116, 0.18);
}

.hero__name {
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-display);
  line-height: var(--lh-hero);
  letter-spacing: var(--ls-display);
  white-space: nowrap;
  margin: 0.1em 0 var(--space-5);
  padding-inline: clamp(0.75rem, 2vw, 2rem);
}
@media (max-width: 420px) {
  .hero__name { font-size: var(--fs-h1); letter-spacing: var(--ls-display); }
}

/* Statement headline: the h1 carries a sentence instead of the name. Sized by
   the h1 token at every width — it used to be capped at 4.1vw and 60px above
   900px, and dropped to the h2 token below 420px, so the page's one h1 was the
   only heading on the site not set by the scale. The token's own clamp holds
   the line length: it tops out below the old 60px cap, so the sentence still
   stops short of the hand. */
.hero__name--statement {
  white-space: normal;
  text-wrap: balance;
  font-size: var(--fs-h1);
  line-height: var(--lh-display);
  letter-spacing: var(--ls-heading);
  max-width: none;
}
/* The note sits close under the statement, so the two read as one block, and
   the statement drops the centred name's side padding so both range off the
   same left edge. */
.hero__name--statement { margin-bottom: var(--space-3); padding-inline: 0; }
/* The statement wraps on its own measure rather than at a break set in the
   markup, so it flows to whatever the column allows. It and the note below it
   are inset from the greeting and the stats, which stay ranged left. */

/* ==========================================================================
   Hero visual: a hand holding a UI component

   The arm runs bottom-to-top in the source, so rotating the whole group 45deg
   anticlockwise swings it in from the bottom-right corner with the hand
   reaching up and in toward the statement — the copy keeps the left of the
   frame, the visual takes the right, and neither crosses the other.
   ========================================================================== */
/* Two columns inside the hero's own measure — copy left, portrait right at the
   container's edge — both centred on each other, with the portrait lifted off a
   solid yellow block offset behind its top-left corner. */
.hero__portrait {
  position: relative;
  margin: 0;
  width: 100%;
  max-width: 396px;
  justify-self: end;
}
.hero__portrait img {
  display: block;
  width: 100%;
  height: auto;
  position: relative;
  z-index: 1;
  border-radius: 14px;
  /* Offsets written by initPortraitParallax. */
  transform: translate(var(--img-x, 0px), var(--img-y, 0px));
  will-change: transform;
}
.hero__portrait::before {
  content: "";
  position: absolute;
  inset: 0;
  transform: translate(calc(-18px + var(--blk-x, 0px)), calc(-18px + var(--blk-y, 0px)));
  background: var(--yellow);
  border-radius: 14px;
  will-change: transform;
}
@media (min-width: 641px) {
  .hero__content .container {
    display: grid;
    grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
    column-gap: var(--space-5);
    align-items: center;
  }
  /* Two cells, not six: the copy is one block, so its own spacing decides the
     gaps inside it rather than the row heights the portrait forces. */
  .hero__copy { grid-column: 1; }
  .hero__portrait { grid-column: 2; }
}
/* Tablets keep the desktop arrangement, just smaller: a narrower gutter
   between the columns and a smaller cap on the photo. */
@media (min-width: 641px) and (max-width: 899px) {
  .hero__portrait { max-width: 300px; }
}
/* Phones have no room for two columns, so the portrait stacks under the copy,
   centred and capped so it stays a portrait rather than a banner. The space
   above the copy and above the photo is halved to suit the shorter screen. */
@media (max-width: 640px) {
  .hero__portrait {
    max-width: min(320px, 68vw);
    justify-self: center;
    margin: calc(var(--space-6) * 0.5) auto 0;
  }
  .hero__stage { padding-top: calc(var(--header-h) + var(--space-6) * 0.5); }
}


.hero__hand {
  --hand-w: clamp(280px, 31vw, 420px);
  position: absolute;
  z-index: 1;                 /* over the scattered letters, under the copy */
  right: -17%;
  top: -8%;
  width: var(--hand-w);
  transform: translate(var(--hand-x, 0px), var(--hand-y, 0px)) rotate(-45deg);
  transform-origin: 60% 70%;
  will-change: transform;
  /* One shadow for the whole group. drop-shadow works on the element's
     composited alpha, so putting it here — rather than on the hand image and
     the card separately — traces the silhouette of hand AND card as a single
     outline. Two separate shadows could never line up: each is offset inside
     its own rotated coordinate space, so they drifted apart as they moved.

     The offset is in this element's space, already rotated -45deg, so it is
     pre-rotated by +45: (0, 20) here lands as roughly (14 right, 14 down). */
  filter: drop-shadow(calc(0px + var(--hsh-x, 0px))
                      calc(20px + var(--hsh-y, 0px))
                      0 var(--yellow));
  pointer-events: none;
}
.hero__hand-img { display: block; width: 100%; height: auto; }
/* A second copy of the same cutout, clipped to the thumb (a curved
   objectBoundingBox clipPath in the markup) and stacked above the card, so the
   hand reads as gripping it: fingers behind, thumb in front. */
.hero__hand-clip { position: absolute; width: 0; height: 0; }
.hero__hand-thumb {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: auto;
  z-index: 2;
  clip-path: url(#hand-thumb-clip);
}

/* The component sits in the pinch — measured on the cutout at 15.1% / 10.1% —
   and counter-rotates so it reads upright while the hand stays at 45deg. */
/* A zero-size anchor dropped on the pinch between thumb and index — measured
   on the cutout at 16% / 10% — and counter-rotated, so the card it holds is
   laid out in page-aligned space rather than the hand's 45deg one. */
.hero__chip-anchor {
  position: absolute;
  left: 16%;
  top: 10%;
  width: 0;
  height: 0;
  transform: rotate(45deg);
}
.hero__chip {
  position: absolute;
  right: 0;
  top: 0;
  /* The card is held at the pinch: its right edge meets the fingertips. */
  transform: translate(-12%, 2%) translate(var(--chip-x, 0px), var(--chip-y, 0px)) rotate(20deg);
  will-change: transform;
  /* Every measurement is a fraction of the hand, so the card keeps its place
     in the fingers whatever size the hand is drawn at (196px at a 320px hand). */
  width: calc(var(--hand-w) * 0.6125);
  display: grid;
  gap: calc(var(--hand-w) * 0.022);
  padding: calc(var(--hand-w) * 0.044) calc(var(--hand-w) * 0.05) calc(var(--hand-w) * 0.05);
  font-size: calc(var(--hand-w) * 0.044);
  border-radius: calc(var(--hand-w) * 0.0375);
  background: var(--paper-alt);
  border: 1px solid var(--border-light);
  /* Label and status centred in the card. */
  justify-items: center;
  text-align: center;
}
.hero__chip-label {
  font-size: 0.82em;
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--eyebrow-ink);
}
/* The live dot in front of the status. */
.hero__chip-dot {
  width: 0.64em;
  height: 0.64em;
  border-radius: 50%;
  background: var(--green);
  flex: none;
}
.hero__chip-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.57em;
  font-size: 1em;
  font-weight: var(--fw-body-bold);
  color: var(--ink);
}

/* The greeting sits above the statement, where the reference puts its own
   lead-in, rather than at the head of the note stack below it. */
.hero__greeting {
  font-size: var(--fs-body);
  font-weight: var(--fw-body-bold);
  color: var(--ink);
  margin-bottom: calc(var(--space-3) * 4);
}

/* The foot: notes ranged left, the accent set large and right, the way the
   reference sets its one word against the stack. */
.hero__foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-5);
  margin-top: var(--space-3);
}
.hero__notes { display: grid; gap: 0.25em; }
/* One sentence, in the darker grey. The two-line stack with the second line
   stepping down to --grey-400 read as an afterthought trailing off; --grey-600
   is 7.4:1 on the page where --grey-400 was 2.5:1. */
/* A 100px asterisk set behind the note, bleeding off its left edge — a mark
   rather than a character, so it is drawn rather than typed. */
.hero__notes { position: relative; }
.hero__mark {
  position: absolute;
  left: -52px;
  top: 50%;
  transform: translate(var(--mark-x, 0px), var(--mark-y, 0px)) translateY(-50%);
  will-change: transform;
  width: 100px;
  height: 100px;
  z-index: 0;
  color: var(--yellow);
  pointer-events: none;
}
/* The spin lives on the SVG, not on .hero__mark — the parent's transform is
   already carrying the pointer parallax, and a second transform there would
   overwrite it. Separate elements, separate jobs.

   20s per turn: slow enough that it reads as "alive" behind the note rather
   than as something moving while you are trying to read. */
.hero__mark svg {
  display: block;
  animation: markSpin 20s linear infinite;
}
@keyframes markSpin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
  .hero__mark svg { animation: none; }
}
.hero__note { position: relative; z-index: 1; }

.hero__note {
  font-size: var(--fs-body);
  font-weight: var(--fw-body);
  color: var(--grey-600);
  /* Runs the full width of the copy column, like the headline above it. */
  max-width: none;
}
@media (max-width: 820px) {
  .hero__foot { flex-direction: column; align-items: flex-start; gap: var(--space-4); }
}

.hero__role-break {
  display: none;
}
@media (min-width: 700px) {
  .hero__role-break { display: inline; }
}

.hero__role {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-tight);
  color: var(--grey-600);
}

.hero__bio {
  font-size: var(--fs-lead);
  line-height: var(--lh-loose);
}

.hero__stats {
  display: flex;
  flex-direction: row;
  /* The two figures read as a pair, so they stay on one line at every width
     and share the column rather than stacking. */
  flex-wrap: nowrap;
  justify-content: flex-start;
  /* 40px between the pair. The 899px and 860px blocks below hold small
     screens at 16px, where the columns are too narrow to spend 40 on air. */
  gap: 40px;
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  /* The rule the pair hangs off — the hero's own divider, between the statement
     and the figures. The padding above is what holds them clear of it. */
  border-top: 1px solid var(--rule-hairline);
}
.hero__stat { flex: 1 1 0; min-width: 0; }
/* Each figure takes the width its own label needs rather than a fixed share,
   so the pair sits as a tight group against the left and neither caption is
   forced to wrap in a column sized for the other one. Measured at 1440: the
   first needs 191px, the second 309px, so 516px of the 661px column.
   Only above 900px — on a phone they still split the row evenly, because
   content-width columns there would be too narrow for either caption. */
@media (min-width: 900px) {
  .hero__stat { flex: 0 1 auto; }
}
/* Narrower columns need a narrower gutter between them, and the stack above
   them tightens by a fifth so the hero holds together on a small screen. */
@media (max-width: 899px) {
  .hero__stats {
    gap: var(--space-2);
    margin-top: calc(var(--space-4) * 0.56);
    padding-top: calc(var(--space-4) * 0.56);
  }
  .hero__name--statement { margin-bottom: calc(var(--space-3) * 0.56); }
  .hero__foot { margin-top: calc(var(--space-3) * 0.56); }
}

.hero__stat b {
  display: block;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-semibold);
}
.hero__stat span {
  font-size: var(--fs-small);
  color: var(--grey-600);
}

/* Declared after the 899px block, so this value is what actually lands on
   small screens — it has to match, not fight it. */
@media (max-width: 860px) {
  .hero__stats { gap: var(--space-2); }
}

/* Oversized wordmark bled off the frame, in the deeper yellow. Decorative and
   aria-hidden: the header already carries her name as text. */
.hero__wordmark {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  user-select: none;
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  line-height: 1;
  color: var(--yellow);
  text-transform: lowercase;
}
/* Scattered, not set in a row, and not ringed evenly around the edge either —
   that reads as a border. Positions come from the R2 low-discrepancy sequence
   over the whole frame, rejecting any point inside the type's measured
   keep-out and any that would crowd a letter already placed. The same method
   the petal field used: it covers unevenly on purpose but never clumps, and
   it is the same scatter on every load because it is derived from the index.
   Size and tilt vary per letter so no two read as a matched pair. */
.hero__wordmark { font-size: clamp(3rem, 8.5vw, 8.5rem); }
/* The rotation lives in CSS and the drift in two custom properties, so the
   script only ever writes --px/--py and never has to re-state the rotation it
   would otherwise overwrite. */
.hero__wordmark span {
  position: absolute;
  transform: translate(calc(-50% + var(--px, 0px)), calc(-50% + var(--py, 0px))) rotate(var(--rot, 0deg));
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .hero__wordmark span { --px: 0px; --py: 0px; }
}
.hero__wordmark span:nth-child(1) { left: 92.8%; top: 22.4%; font-size: 1.04em; --rot: 3.6deg; --depth: 0.83; --phase: 0.79; }
.hero__wordmark span:nth-child(2) { left: 25.1%; top: 87.7%; font-size: 0.92em; --rot: -7.6deg; --depth: 0.60; --phase: 1.57; }
.hero__wordmark span:nth-child(3) { left: 94.6%; top: 48.1%; font-size: 0.79em; --rot: 7.3deg; --depth: 0.35; --phase: 2.35; }
.hero__wordmark span:nth-child(4) { left: 72.0%; top: 8.5%; font-size: 1.17em; --rot: -3.9deg; --depth: 1.08; --phase: 3.14; }
.hero__wordmark span:nth-child(5) { left: 4.4%; top: 73.8%; font-size: 1.05em; --rot: 10.9deg; --depth: 0.85; --phase: 3.93; }
.hero__wordmark span:nth-child(6) { left: 51.3%; top: 86.6%; font-size: 0.93em; --rot: -0.3deg; --depth: 0.62; --phase: 4.71; }
.hero__wordmark span:nth-child(7) { left: 6.2%; top: 7.5%; font-size: 0.80em; --rot: -11.5deg; --depth: 0.37; --phase: 5.50; }
.hero__wordmark span:nth-child(8) { left: 7.9%; top: 33.2%; font-size: 1.18em; --rot: 3.3deg; --depth: 1.10; --phase: 6.28; }

/* The hero's content sits above the mark — otherwise the role line and the
   figures read against the letterforms rather than the ground. */
.hero__content { position: relative; z-index: 2; }

/* ==========================================================================
   Hero type
   Ink on the page's own ground. Yellow is an accent here, not a field: it
   marks the statement and the figures, which is the only way it can be used
   on paper at all — yellow type measures 1.02:1, yellow behind ink 16.5:1.
   ========================================================================== */


.hero__name,
.hero__stat b {
  color: var(--ink);
}
.hero__role,
.hero__stat span {
  color: var(--grey-600);
}
/* The eyebrow is left to .eyebrow's own rule so it tracks --eyebrow-ink
   rather than being pinned here alongside the supporting copy. */

/* Restraint: wider tracking, lighter weight, hairline rules */
.hero .eyebrow {
  letter-spacing: var(--ls-wide);
  font-weight: var(--fw-medium);
}
.hero .eyebrow.eyebrow--sentence { letter-spacing: 0; }
.hero__role { font-weight: var(--fw-regular); }

/* ==========================================================================
   Hero — title sequence
   Five beats, all read from one scroll position. JS writes the progress
   variables; CSS spends them on transform and opacity only, so every frame
   stays on the compositor.
     --h-title  title card sliding out to the left
     --h-name   her name arriving from the right and settling to size
     --h-copy   role and stats fading up
     --h-img    the photograph rising out of the black
   ========================================================================== */

/* Name the content explicitly rather than excluding backdrops one at a time.
   A blanket `.hero__stage > *` rule kept overriding position:absolute on the
   layers that set it themselves — it collapsed the photograph to zero height,
   knocked the title card out of centre, and clipped the work backdrop away. */
.hero__stage .container,
.hero__name { position: relative; z-index: 2; }
/* .hero__stage is a column flex container, and .container's margin-inline:auto
   is an auto margin on its cross axis — which suppresses the stretch a flex
   item would otherwise take, leaving it sized to its content and a few pixels
   short of the shell every other section fills. Stated explicitly so the hero's
   content line starts and ends where the rest of the page's does. */
.hero__stage .container { width: 100%; }

/* The type is the whole opening now that the petal field is gone, so it starts
   almost at once — the ~1s hold existed only to let the petals land first, and
   without them it was a second of empty black. */

/* Beat 4, automatic: the supporting lines settle in under her name, then the
   photograph rises out of the black behind all of it. */
/* Same cue as the name and the photograph — the whole frame resolves at once.
   The 120ms offsets are not a queue, just enough that the three lines do not
   land on the identical frame and read as a single block dropping in. */
/* Same cue as her name: the frame and the name arrive together, so the black
   resolves into the photograph in the same breath as "Lydia Ong" appears.
   Longer ramp than the name so it emerges rather than switching on. */

/* Beat 3: her name comes in from the right, oversized, and settles down to
   its real size as it lands. */
.hero__content {
  display: contents;
}
.hero__name,
.hero__kicker,
.hero__copy,
.hero__stats {
  will-change: transform, opacity;
}
/* At rest her name is simply in place — the intro animates *to* this, so the
   page is correct with JS off, with animations off, and after the sequence. */
.hero__name { transform: none; opacity: 1; }
/* At rest everything is simply present; the intro animates to this state. */
.hero__kicker,
.hero__copy,
.hero__stats { opacity: 1; transform: none; }

/* Reduced motion / no JS: the finished hero, plainly. */
.hero--static .hero__name,
.hero--static .hero__kicker,
.hero--static .hero__copy,
.hero--static .hero__stats { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .hero { min-height: 78dvh; }
  .hero__stage { position: relative; height: auto; min-height: 78dvh; }
  .hero__name,
  .hero__kicker,
  .hero__copy,
  .hero__stats { opacity: 1 !important; transform: none !important; animation: none !important; }
}


/* Inline link inside body copy — underlined, since colour alone is not a cue. */
.cs-link {
  text-decoration: underline;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
  text-decoration-color: var(--grey-400);
}
.cs-link:hover { text-decoration-color: currentColor; }

/* ==========================================================================
   Selected works — inverted, typographic list
   ========================================================================== */

.section--dark {
  background: var(--bg-inverse);
  color: var(--fg-inverse);
}

.section-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
  flex-wrap: wrap;
}
/* The works grid speaks for itself on a wide screen; on narrower ones the
   cards arrive without context, so the heading comes back. */
.section-head--small-only { display: none; }
@media (max-width: 1060px) {
  .section-head--small-only { display: flex; }
  /* Halve the seam between the hero and the works, which is a hole on a
     screen this size. */
  .hero__stage { padding-bottom: calc(var(--space-7) * 0.25); }
  /* Tablet and below tighten the hero seam further, per the hero's own
     reduced stage padding above. */
  #work { padding-top: calc(var(--section-gap) * 0.5); }
}

.section-head h2 {
  font-family: var(--font-display);
  font-size: var(--fs-h1);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
}
.section-head p {
  max-width: 40ch;
  color: var(--grey-400);
}
.section:not(.section--dark) .section-head p { color: var(--grey-600); }

/* Selected works — a 2x2 grid of project cards. The list that was here showed
   four titles and no work; each card now carries the project's own preview
   beside a short account of what it was. */
.work-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-5);
}
/* The card is a panel: text down the left, the preview filling the right. */
.work-card {
  display: grid;
  grid-template-columns: 1.1fr 1fr;
  /* Top-aligned, so every card starts its title on the same line. Centred, each
     text block was centred against its own height — a three-line title with four
     tags sat higher than a two-line title with three, and no two cards in a row
     began at the same place. The preview keeps its own centring below. */
  align-items: start;
  gap: var(--space-4);
  padding: var(--space-4);
  border-radius: 14px;
  /* The card is the mask: the preview is oversized and tilted, and the card's
     own rounded rect is what cuts it off. */
  overflow: hidden;
  background: var(--paper-alt);
  border: 1px solid var(--border-light);
  /* The page is now within 1.02 of the card, so the panel reads as raised
     by elevation rather than by tone. Warm-tinted rather than neutral black,
     so it sits in the bone rather than greying it. */
  box-shadow: 0 1px 2px rgba(80, 66, 40, 0.04),
              0 8px 24px -12px rgba(80, 66, 40, 0.10);
  text-decoration: none;
  color: inherit;
  transition: background var(--dur-mid) var(--ease-out);
}
.section--dark .work-card { background: #141414; }

/* min-width:0 on both tracks: a grid item's default min-width is its content's
   intrinsic size, so the preview's natural width was pushing the card wider
   than its column and the page sideways. */
/* A fifth tighter than the shared step: title, client, description and tags
   are one block, and the full gap had them reading as four separate things. */
.work-card__text { display: grid; align-content: start; gap: calc(var(--space-2) * 0.64); min-width: 0; }
.work-card__title {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
  line-height: var(--lh-tight);
  text-wrap: balance;
  transition: color var(--dur-mid) var(--ease-out);
}
/* The same marker sweep the forward link takes: the accent goes behind the
   words rather than into them, because yellow type measures 1.02:1 on paper
   and would vanish. The inner span exists because .work-card__text is a grid
   and a grid item is blockified — the band would be one rectangle the width
   of the column instead of following each line. */
.work-card__title-mark {
  display: inline;
  padding: 0.04em 0.1em;
  /* The band's own left padding pushed the first letter 2px right of the
     client line and description below it. Pulled back by the same amount, so
     the glyphs share the card's left edge and the highlight still overhangs. */
  margin-left: -0.1em;
  background-image: linear-gradient(var(--yellow), var(--yellow));
  background-repeat: no-repeat;
  background-position: left center;
  background-size: 0% 100%;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  transition: background-size var(--dur-mid) var(--ease-out);
}
/* The client, set as a label — the one piece of context a recruiter scans for
   before the description. */
.work-card__client {
  font-size: var(--fs-label);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  color: var(--eyebrow-ink);
}
.work-card__desc {
  font-size: var(--fs-body);
  line-height: var(--lh-snug);
  color: var(--grey-400);
  max-width: 34ch;
}
.section:not(.section--dark) .work-card__desc { color: var(--grey-600); }

.work-card__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  /* A fifth tighter, like the gaps above it: the chips belong to the card's
     text block rather than sitting apart from it. */
  margin-top: calc(var(--space-2) * 0.8);
}
.work-card__tag {
  font-size: var(--fs-label);
  line-height: 1;
  padding: 7px 12px;
  border-radius: 999px;
  color: var(--grey-400);
  background: rgba(255, 255, 255, 0.06);
}
.section:not(.section--dark) .work-card__tag {
  color: var(--grey-600);
  background: rgba(10, 10, 10, 0.05);
}

/* The previews are transparent PNGs of app screens, so the frame gives them a
   ground of their own and contains rather than crops: a preview is only useful
   if its edges survive. */
.work-card__frame {
  display: block;
  position: relative;
  /* The copy tops out; the artwork still sits in the middle of the card. */
  align-self: center;
  /* Visible, so the oversized preview spills out of its own box and is clipped
     by the card instead. The frame only holds the space in the grid. */
  overflow: visible;
  /* Width from the column, height from the ratio. With align-self:stretch the
     row's height drove the width instead, and the frame grew past the card. */
  width: 100%;
  aspect-ratio: 4 / 3;
  min-width: 0;
}
.work-card__img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  /* Oversized and slightly off-square, so the preview reads as an object laid
     on the card rather than a thumbnail placed in a slot. Grown from the left
     edge: the extra width all goes rightward, where the card's own edge cuts
     it off. Scaling from the centre would push it into the text column. */
  transform: rotate(3deg) scale(1.44);
  transform-origin: left center;
  transition: transform var(--dur-mid) var(--ease-out);
}

/* The cards grow in as they arrive. .work-card declares its own transition
   for the hover wash, and at equal specificity that rule would replace the
   one on [data-reveal] and make the entrance snap — so the attribute
   selector restates all three properties together. */
.work-card[data-reveal] {
  transform: translateY(20px) scale(0.95);
  transition: opacity var(--dur-slow) var(--ease-out),
              transform var(--dur-slow) var(--ease-out),
              background var(--dur-mid) var(--ease-out);
}
.work-card[data-reveal].is-visible { transform: none; }

@media (prefers-reduced-motion: reduce) {
  /* Beats the generic [data-reveal] reset, which this selector outranks. */
  .work-card[data-reveal],
  .work-card[data-reveal].is-visible { transform: none; transition: none; }
}

.work-card:hover,
.work-card:focus-visible { background: #1b1b1b; }
.section:not(.section--dark) .work-card:hover,
.section:not(.section--dark) .work-card:focus-visible { background: var(--paper); }
.work-card:hover .work-card__img,
.work-card:focus-visible .work-card__img { transform: rotate(3deg) scale(1.51); }
/* Two devices side by side rather than one screen, so this preview carries its
   own scale. */
.work-card__img--sm { transform: translateY(0%) rotate(3deg) scale(1.77); }
.work-card:hover .work-card__img--sm,
.work-card:focus-visible .work-card__img--sm { transform: translateY(0%) rotate(3deg) scale(1.86); }
/* Desktop only: the two-device preview is pulled left in its frame, so the card
   leads with the first phone's reading rather than the gap between the two. The
   hover state carries the same offset, so the only thing that changes on hover
   is the scale. Note the offset is a percentage of the element, which means it
   scales with it — raising the scale moves the artwork further, and far enough
   will uncover an edge of the frame. */
@media (min-width: 1025px) {
  .work-card__img--sm { transform: translate(-18%, 0%) rotate(3deg) scale(1.77); }
  .work-card:hover .work-card__img--sm,
  .work-card:focus-visible .work-card__img--sm { transform: translate(-18%, 0%) rotate(3deg) scale(1.86); }
}
/* A tenth under the shared scale: a portrait diagram rather than a screenshot,
   so the oversize treatment cropped its labels sooner than the others. */
.work-card__img--eco { transform: translateY(20%) rotate(3deg) scale(1.296); }
.work-card:hover .work-card__img--eco,
.work-card:focus-visible .work-card__img--eco { transform: translateY(20%) rotate(3deg) scale(1.359); }
/* Dropped into the frame so the tops of the screens clear its upper edge. */
/* A touch under the shared crop, and sitting higher in the frame to match: at
   the original 20% drop this scale left a sliver of the card showing above the
   artwork, because the image is only just taller than the frame it fills. */
.work-card__img--app { transform: translateY(2%) rotate(3deg) scale(1.99); }
.work-card:hover .work-card__img--app,
.work-card:focus-visible .work-card__img--app { transform: translateY(2%) rotate(3deg) scale(2.09); }
/* Dropped into the frame, like the other three previews. */
/* A third larger than the other cards' crop. This preview fans a set of screens
   across its width, so at the shared scale each one shrank to an illegible grey
   block; zooming in trades the full spread for screens you can actually read at
   card size. The hover step keeps its same proportion above the resting state. */
.work-card__img--care { transform: translateY(10%) rotate(3deg) scale(3.74); }
.work-card:hover .work-card__img--care,
.work-card:focus-visible .work-card__img--care { transform: translateY(10%) rotate(3deg) scale(3.92); }
/* Tablet: the card is narrower than on desktop but its frame keeps the same
   proportion, so the desktop crop magnified each device past what the smaller
   card can carry. Both previews pull back a third; the horizontal shift goes
   with them, since a percentage offset scales with the element it moves. */
@media (min-width: 701px) and (max-width: 1024px) {
  .work-card__img--sm { transform: translate(-10%, 0%) rotate(3deg) scale(1.24); }
  .work-card:hover .work-card__img--sm,
  .work-card:focus-visible .work-card__img--sm { transform: translate(-10%, 0%) rotate(3deg) scale(1.30); }
  .work-card__img--app { transform: translateY(0%) rotate(3deg) scale(1.39); }
  .work-card:hover .work-card__img--app,
  .work-card:focus-visible .work-card__img--app { transform: translateY(0%) rotate(3deg) scale(1.46); }
  /* The Care preview keeps its desktop zoom but sits higher in the tablet card,
     where the taller frame otherwise showed the empty sheet above the screens. */
  .work-card__img--care { transform: translateY(-35%) rotate(3deg) scale(2.16); }
  .work-card:hover .work-card__img--care,
  .work-card:focus-visible .work-card__img--care { transform: translateY(-35%) rotate(3deg) scale(2.26); }
}
/* Mobile: the card stacks, so the preview sits under the copy with the full
   column to itself and no text beside it to play against. At the desktop crop
   it filled that space edge to edge and ran up over the paragraph above it;
   halved, it reads as an object on the card rather than a backdrop behind the
   words. Declared after the base rules so it wins on order, not specificity. */
@media (max-width: 700px) {
  /* A shorter frame for the preview, which is what sets the card's height once
     the layout stacks: the copy is as long as it is, and the 4:3 box under it
     was adding half again as much. 16:9 takes roughly a tenth off the card
     without touching the type or the padding. */
  .work-card__frame { aspect-ratio: 16 / 9; }
  .work-card__img--sm { transform: translate(-10%, 8%) rotate(3deg) scale(1.30); }
  .work-card:hover .work-card__img--sm,
  .work-card:focus-visible .work-card__img--sm { transform: translate(-10%, 8%) rotate(3deg) scale(1.35); }
  .work-card__img--app { transform: translateY(12%) rotate(3deg) scale(1.32); }
  .work-card:hover .work-card__img--app,
  .work-card:focus-visible .work-card__img--app { transform: translateY(12%) rotate(3deg) scale(1.39); }
  .work-card__img--care { transform: translateY(28%) rotate(3deg) scale(1.87); }
  .work-card:hover .work-card__img--care,
  .work-card:focus-visible .work-card__img--care { transform: translateY(28%) rotate(3deg) scale(1.96); }
}
.work-card:hover .work-card__title,
.work-card:focus-visible .work-card__title { color: var(--ink); }
.work-card:hover .work-card__title-mark,
.work-card:focus-visible .work-card__title-mark { background-size: 100% 100%; }

/* Tablet: the card is a single column of the grid and taller than it is wide,
   so growing from the vertical centre cropped the top of every preview — the
   part that carries the status bar and the screen's own header. Anchoring to
   the top-left sends all the growth right and down instead, and only those
   two edges are cut, and only where the preview actually runs past them. */
@media (min-width: 621px) and (max-width: 1100px) {
  .work-card__img { transform-origin: left top; }
}

@media (prefers-reduced-motion: reduce) {
  .work-card__img { transition: none; }
  .work-card:hover .work-card__img { transform: rotate(3deg) scale(1.44); }
}
/* One card per row, then the card itself stacks: at half of half a screen the
   text column has no measure left. */
@media (max-width: 1100px) {
  .work-grid { grid-template-columns: 1fr; }
}
@media (max-width: 620px) {
  .work-card { grid-template-columns: 1fr; padding: var(--space-4); }
  .work-card__desc { max-width: none; }
  /* Half the seam the card's own row gap gives it, and half again on the
     frame: with the preview dropped into its frame the empty band above the
     screens read as a hole between the tags and the image. */
  .work-card { row-gap: calc(var(--space-4) * 0.5); }
  .work-card__frame { margin-top: calc(var(--space-4) * 0.2); }
}

/* ==========================================================================
   Ground variants — comparison only (index-light.html / index-dark.html).
   Delete this block once the homepage's ground is decided.
   ========================================================================== */
.hero--light { background: var(--paper); color: var(--ink); }
.hero--light .hero__name,
.hero--light .hero__role,
.hero--light .hero__stat b { color: var(--ink); }
.hero--light .hero__copy,
.hero--light .hero__stat span { color: var(--grey-600); }
.contact--light { background: var(--paper-alt); color: var(--ink); }
.contact--light .contact__title span { color: var(--ink); }
.contact--light .btn--ghost { border-color: var(--ink); color: var(--ink); }

/* ==========================================================================
   Approach / capability strip
   ========================================================================== */

.capabilities {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-5);
}
.capability h3 {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-semibold);
  margin-bottom: var(--space-2);
  letter-spacing: var(--ls-tight);
}
.capability p { color: var(--grey-600); font-size: var(--fs-body); }

@media (max-width: 860px) {
  .capabilities { grid-template-columns: 1fr; gap: var(--space-5); }
}

/* ==========================================================================
   Work history / resume
   ========================================================================== */

.resume-top {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}

/* Subtitle stacks directly above the download button, both right-aligned to the
   button's edge, so the heading is left alone on its own side. */
.resume-top__aside {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-2);
}
.resume-top__sub {
  color: var(--grey-600);
  font-size: var(--fs-small);
  text-align: right;
  max-width: 34ch;
  margin: 0;
}

@media (max-width: 700px) {
  .resume-top__aside { align-items: flex-start; width: 100%; }
  .resume-top__sub { text-align: left; }
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  padding: 0.95em 1.6em;
  font-weight: var(--fw-semibold);
  font-size: var(--fs-small);
  border: 1.5px solid var(--ink);
  transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast), transform var(--dur-fast) var(--ease-out);
}
.btn:hover { transform: translateY(-2px); }
/* Ink ground, yellow text — both straight from the palette (19:1). It inverts
   on hover to yellow with ink type, which measures the same. */
.btn--fill { background: var(--ink); color: var(--yellow); border-color: var(--ink); }
.btn--fill:hover { background: var(--yellow); color: var(--ink); border-color: var(--yellow); }
.btn--accent { background: var(--yellow); color: var(--ink); border-color: var(--yellow); }
.btn--accent:hover { background: var(--ink); color: var(--yellow); border-color: var(--ink); }
.btn[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
.btn[aria-disabled="true"]:hover { transform: none; }
.btn--ghost { border-color: currentColor; }
.btn--ghost:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.section--dark .btn--ghost { border-color: var(--paper); }
.section--dark .btn--ghost:hover { background: var(--yellow); color: var(--ink); border-color: var(--yellow); }

/* The clients the roles below name, gathered into one line under the resume
   download. Set as type rather than logos — these are third-party marks and
   the project holds no artwork for them. */
.client-strip { margin-bottom: var(--space-7); }
/* A sentence, so it drops the uppercase label treatment the short eyebrows use
   — tracked-out caps are unreadable at this length. */
.client-strip__label {
  display: block;
  font-size: var(--fs-small);
  font-weight: var(--fw-body);
  color: var(--eyebrow-ink);
  margin-bottom: var(--space-3);
}
.client-strip__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}
.client-strip__list li {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
  line-height: var(--lh-tight);
  /* Body ink, like every other passage on the page. These were set in
     --grey-400 and only came up to ink on hover, which meant the list read as
     disabled text until a pointer touched it — and never on a phone. */
  color: var(--ink);
}
/* Sub-brands ride with their parent rather than counting as separate clients. */
.client-strip__list li span {
  font-family: var(--font-body);
  font-size: var(--fs-small);
  font-weight: var(--fw-body);
  letter-spacing: normal;
  color: var(--grey-600);
}
/* Attribution, kept quiet — it is a legal note, not part of the list. */
.client-strip__note {
  margin: var(--space-3) 0 0;
  font-size: var(--fs-micro);
  color: var(--grey-400);
}

.resume-block { margin-bottom: var(--space-7); }
/* Nothing follows the last block inside the section, so its trailing margin
   only stacks on the section's own padding. */
.resume-block:last-of-type { margin-bottom: 0; }
.resume-block__label {
  font-family: var(--font-display);
  font-size: var(--fs-small);
  font-weight: var(--fw-bold);
  text-transform: uppercase;
  letter-spacing: var(--ls-label);
  color: var(--eyebrow-ink);
  margin-bottom: var(--space-4);
  display: block;
}

.role-entry {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: var(--space-4);
  padding-block: var(--space-4);
}
.role-entry__period {
  font-size: var(--fs-small);
  color: var(--grey-600);
  font-weight: var(--fw-medium);
}
.role-entry__title {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
}
.role-entry__org { color: var(--grey-600); margin-top: 0.15em; }
.role-entry__desc {
  margin-top: var(--space-2);
  max-width: 62ch;
  color: var(--ink);
}
.role-entry__desc li { position: relative; padding-left: 1.2em; margin-top: 0.35em; }
.role-entry__desc li::before {
  content: '';
  position: absolute; left: 0; top: 0.65em;
  width: 5px; height: 5px;
  background: var(--green);
}

.placeholder-tag {
  display: inline-block;
  margin-top: var(--space-2);
  font-size: var(--fs-label);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-label-tight);
  text-transform: uppercase;
  color: var(--ink);
  background: var(--yellow);
  padding: 0.3em 0.6em;
}

@media (max-width: 700px) {
  .role-entry { grid-template-columns: 1fr; gap: var(--space-1); }
}

.edu-skills {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-6);
}
@media (max-width: 700px) {
  .edu-skills { grid-template-columns: 1fr; gap: var(--space-5); }
}

.skills-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
}
.skills-tags li {
  border: 1.5px solid var(--ink);
  padding: 0.5em 1em;
  font-size: var(--fs-small);
  font-weight: var(--fw-medium);
}

/* ---- Resume: intro summary ---- */
/* The download sits under the summary it belongs to, ranged left with it. */
.resume-actions {
  margin: var(--space-4) 0 var(--space-6);
}

.resume-intro {
  font-size: var(--fs-lead);
  line-height: var(--lh-loose);
  max-width: 62ch;
  margin-bottom: var(--space-4);
  color: var(--ink);
}

/* ---- Resume: numbered focus areas inside a role ---- */
.role-entry__areas {
  counter-reset: area;
  margin-top: var(--space-3);
  display: grid;
  gap: var(--space-4);
}
.role-entry__areas > li {
  counter-increment: area;
  display: grid;
  grid-template-columns: 1.9rem 1fr;
  gap: 0.35rem var(--space-2);
  align-items: start;
}
.role-entry__areas > li::before {
  content: counter(area);
  font-family: var(--font-display);
  font-size: var(--fs-label);
  font-weight: var(--fw-bold);
  color: var(--ink);
  background: var(--yellow);
  width: 1.55rem;
  height: 1.55rem;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  grid-row: span 2;
}
.role-entry__areas h4 {
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-snug);
  margin: 0.2em 0 0;
}
.role-entry__areas p {
  margin: 0.35em 0 0;
  max-width: 64ch;
  color: var(--grey-600);
  font-size: var(--fs-small);
  grid-column: 2;
}

/* ---- Resume: plain role description + key clients ---- */
.role-entry__desc-p {
  margin-top: var(--space-2);
  max-width: 64ch;
  /* The supporting line under a role's title and org — the same relationship
     .role-entry__areas p has in the entry above it, so the same colour. */
  color: var(--grey-600);
  font-size: var(--fs-body);
}
.role-entry__clients {
  margin-top: var(--space-2);
  font-size: var(--fs-small);
  color: var(--grey-600);
  max-width: 64ch;
}
.role-entry__clients span {
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  text-transform: uppercase;
  letter-spacing: var(--ls-label);
  font-size: var(--fs-micro);
  color: var(--ink);
  margin-right: 0.5em;
}

/* ---- Resume: awards ---- */
/* A halftone ground inside each award card rather than behind the run of
   them. It sits on its own layer so the opacity is one number to dial, and
   the card's radius clips it; the text rides above on its own stacking
   context, so the citation stays on paper rather than on dots. */
.resume-block--awards .award {
  position: relative;
  overflow: hidden;
}
.resume-block--awards .award::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background-image: url("../assets/texture/comic-halftone.webp");
  background-size: cover;
  background-position: center;
  opacity: 0.096;
}
.resume-block--awards .award > * { position: relative; z-index: 1; }
/* Phones: the card stacks, so the fade runs down the page rather than across
   it — grain under the title and citation, clean paper by the time the
   description ends. */
@media (max-width: 700px) {
  .resume-block--awards .award::before {
    -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, rgba(0, 0, 0, 0) 100%);
    mask-image: linear-gradient(to bottom, #000 0%, #000 45%, rgba(0, 0, 0, 0) 100%);
  }
}
/* Tablet and up: the texture holds across the left of the card and falls away
   to nothing at the right edge, so the citation keeps its grain and the
   description sits on clean paper. */
@media (min-width: 701px) {
  .resume-block--awards .award::before {
    -webkit-mask-image: linear-gradient(to right, #000 0%, #000 45%, rgba(0, 0, 0, 0) 100%);
    mask-image: linear-gradient(to right, #000 0%, #000 45%, rgba(0, 0, 0, 0) 100%);
  }
}

.award-list { display: grid; gap: var(--space-4); }
.award {
  /* Was 20px to clear the diamond mark before each title; with the mark gone
     the entry sits flush with the column. */
  --award-indent: 0px;
  /* The site's card treatment, as the testimonials take it: each award is its
     own record, and the surface gives the two columns a box to sit in rather
     than floating in the page. The two-column layout inside is unchanged. */
  padding: var(--space-4);
  background: var(--paper-alt);
  border: 1px solid var(--border-light);
  border-radius: 14px;
  box-shadow: 0 1px 2px rgba(80, 66, 40, 0.04),
              0 8px 24px -12px rgba(80, 66, 40, 0.10);
  /* Title and citation on the left, what it was for on the right. The two
     columns start on the same baseline, so the row scans as one record
     rather than as a heading with a paragraph hanging under it. */
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  column-gap: var(--space-4);
  align-items: start;
}
.award__head { display: flex; flex-direction: column; gap: 0.2rem; }
.award h4 {
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-snug);
  position: relative;
  padding-left: var(--award-indent);
}
.award__meta { font-size: var(--fs-label); color: var(--grey-600); padding-left: var(--award-indent); }
.award p {
  /* No top margin in the two-column form: it is beside the title, not under
     it. The single-column fallback below puts it back. */
  margin: 0;
  max-width: 62ch;
  color: var(--ink);
  /* The body token, like every other passage of prose on the site. This ran a
     step down at --fs-small, which made the award citations the only body copy
     set smaller than the rest. */
  font-size: var(--fs-body);
}

/* Below 900 there is not enough width for two readable measures, so the
   entry stacks and the description picks its indent and spacing back up. */
@media (max-width: 900px) {
  .award { display: block; }
  .award p {
    margin-top: var(--space-2);
    padding-left: var(--award-indent);
    max-width: 68ch;
  }
}

/* ---- Resume: education + certificates ---- */
.edu-entry {
  display: grid;
  grid-template-columns: 76px 1fr;
  gap: var(--space-3);
  padding-block: var(--space-3);
}
.edu-entry__year { font-size: var(--fs-small); color: var(--grey-600); font-weight: var(--fw-medium); }
.edu-entry__title { font-family: var(--font-display); font-weight: var(--fw-semibold); font-size: var(--fs-body); }
.edu-entry__org { color: var(--grey-600); font-size: var(--fs-small); margin-top: 0.15em; }

.cert-list { display: grid; gap: 0; }
.cert {
  padding-block: var(--space-3);
}
.cert h4 {
  font-family: var(--font-display);
  font-weight: var(--fw-semibold);
  font-size: var(--fs-body);
  line-height: var(--lh-snug);
}
.cert span { display: block; margin-top: 0.2em; font-size: var(--fs-label); color: var(--grey-600); }

@media (max-width: 700px) {
  .edu-entry { grid-template-columns: 1fr; gap: var(--space-1); }
  .role-entry__areas > li { grid-template-columns: 1.6rem 1fr; }
}

/* ==========================================================================
   Contact / "let's talk"
   ========================================================================== */

.contact {
  /* Declared on the section, not on the hand: the stack of contact details
     sits below the hand and has to clear it, so both need the same number. */
  --hand-w: clamp(220px, 22vw, 320px);
  position: relative;
  overflow: hidden;
  background: var(--paper);
  color: var(--ink);
  text-align: left;
}
/* The hand moved here from the hero: it reaches in from the right of the
   closing section, holding the status card beside the invitation to talk. */
.contact .hero__hand {
  /* .hero__hand sets its own --hand-w for the hero. Here the section owns the
     number, because the contact stack below has to clear the hand — so this
     takes the inherited value rather than the element's own default. */
  --hand-w: inherit;
  right: -12%;
  /* The rotated box carries ~0.456 x its width of empty space above the first
     painted pixel, so 0.447 was the highest the visual could ride before the
     section — which clips — cut into the fingers. Pulled back to 0.08, which
     simply sits it lower; the crop was never the reason for the exact number,
     only its ceiling. The narrow band below sets its own. */
  top: calc(-0.08 * var(--hand-w));
}
/* Narrow screens keep the hand — it moves below the centred copy and the
   section makes room for it, rather than being hidden. */
@media (max-width: 1060px) {
  /* The visual takes the right half of the page and runs off the corner, so
     the cut end of the forearm is always outside the section rather than
     stopping in open space. The section opens up below the copy to hold it. */
  .contact { --hand-w: 46vw; padding-top: calc(var(--space-2) + 27vw); padding-bottom: var(--space-7); }
  .contact .hero__hand {
    /* Placed by the card, not the hand: this puts the card's left edge on the
       page's midline, so the whole visual sits in the right half. It hangs
       from the top of the section and the arm runs off the top-right corner.
       Shifted 20% of the page further left than that midline placement. */
    left: 77%;
    right: auto;
    top: calc(-0.43 * var(--hand-w));
    bottom: auto;
  }
}
/* Phones: close the space between the visual and the copy it introduces. */
/* Tablets have more width than a phone but the same single column, so the
   visual is drawn smaller there than the 46vw the narrow band uses. */
@media (min-width: 641px) and (max-width: 1060px) {
  .contact { --hand-w: 34vw; }
  /* The smaller visual needs less room above the copy than the 27vw the band
     reserves for a phone-sized hand. */
  .contact { padding-top: calc(var(--space-2) + 22vw); }
}
@media (max-width: 640px) {
  /* The band above reserves room for the hand; a phone's is drawn largest in
     proportion, but it ran out past the visible artwork and left the title
     stranded. Trimmed to sit under the end of the marker stroke. */
  .contact { padding-top: calc(var(--space-2) + 24vw); }
}
.contact .container { position: relative; z-index: 2; }
/* Two columns: the statement on the left, the ways to reach her stacked on
   the right. The aside starts below the hand rather than beside it, so the
   photograph keeps the top-right corner to itself. */
/* One column at every width: the heading, then the ways to reach her beneath
   it. The two-column arrangement that used to kick in above 901px is gone —
   the only thing that still needs the hand's measurements is the top offset,
   which keeps the heading clear of the photograph above it. */
@media (min-width: 901px) {
  /* Now that the layout is stacked, the contact details sit below the heading
     rather than under the card, so the card's shadow no longer sets the
     limit. The heading's text ends around the half-way mark and the hand
     occupies the right edge, so the two never meet horizontally — this only
     has to clear the lowest part of the card on the left of the image. */
  .contact { --clear-hand: calc(var(--hand-w) * 0.15); }
  .contact__lead { margin-top: var(--clear-hand); }
  /* The wrapper owns that space, so the title does not add its own on top. */
  .contact__lead .contact__title { margin-top: 0; }
}
.contact__aside {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-1);
}
.contact__aside { margin-top: var(--space-4); }


/* URL then mark, mirroring the email row above it: text first, the control
   to its right. One link wraps both, so it announces once — the mark is
   aria-hidden and the visible URL is the accessible name, which is why there
   is no aria-label here. */
.contact__social {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  font-size: var(--fs-body);
  color: var(--ink);
}
/* The box keeps its own treatment; only the URL beside it is highlighted,
   since a yellow bar behind an ink-filled box would just frame it. */
.contact__social-text {
  position: relative;
  isolation: isolate;
  padding-bottom: 0.15em;
}

/* The filled-button pair: ink ground, as .btn--fill uses, with the mark in
   white at 17.7:1 on it. 40x40 matches the email row's height and clears the
   pointer-target minimum. */
.contact__social-box {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 40px;
  height: 40px;
  background: var(--ink);
  border-radius: 8px;
  color: var(--white);
}
.contact__social svg { flex: none; }

.contact__title {
  font-family: var(--font-display);
  font-size: var(--fs-h1);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-display);
  line-height: var(--lh-display);
  /* 24px down to the line that follows it; the space above stays at 32. */
  margin-block: var(--space-4) var(--space-3);
}
/* The address and its copy control travel together, so the button stays put
   when the row wraps. The pair sits on its own surface — a step down from the
   page in the same hue, which keeps ink at 15.3:1 on it. */
.contact__email {
  display: inline-flex;
  align-items: center;
  /* The copy control is sized to 24px and regains its 44px target through a
     transparent ::after, so the touch minimum is met without the button
     dictating the height of the row. */
  height: 40px;
  /* Wide enough that the button's 44px box clears the address: a narrower
     gap let the hit area overlap the last characters, so a click aimed at
     the end of the email opened the copy control instead of the mail link. */
  gap: 0.6em;
}
/* 44x44 target around an 18px glyph — the icon keeps its optical size while
   the hit area meets the touch minimum. The negative margin stops that
   padding from pushing the glyph away from the address. */
.contact__copy {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: relative;
  width: 24px;
  height: 24px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--grey-600);
  cursor: pointer;
  border-radius: 8px;
  transition: color var(--dur-fast) var(--ease-out);
}
/* The visual glyph is 24px; this is the 44px target around it. Transparent
   and pointer-events-inherited, so it enlarges the hit area without taking
   any space in the row. */
.contact__copy::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}
.contact__copy:hover,
.contact__copy:focus-visible { color: var(--ink); }
.contact__copy.is-done { color: var(--ink); }

/* Both glyphs sit in the same cell and cross-fade, so the button never
   changes size and the row cannot reflow mid-interaction. */
.contact__copy-icon {
  grid-area: 1 / 1;
  position: absolute;
  transition: opacity var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out);
}
.contact__copy-icon--done { opacity: 0; transform: scale(0.7); }
.contact__copy.is-done .contact__copy-icon--copy { opacity: 0; transform: scale(0.7); }
.contact__copy.is-done .contact__copy-icon--done { opacity: 1; transform: none; }

/* Footer: mark plus label, matching the back-to-top treatment beside it.
   The mark is decorative — the word carries the accessible name, so the svg
   stays hidden from the accessibility tree rather than being announced twice. */
.footer-link {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  font-weight: var(--fw-semibold);
  transition: color var(--dur-fast) var(--ease-out);
}
.footer-link svg { flex: none; }
.footer-link:hover { color: var(--ink); }

/* The confirmation is announced, not drawn — it would otherwise be a second
   thing moving in a section that already has the hand and the card. */
/* A toast, not a line in the layout: fixed to the bottom of the viewport and
   centred, so the confirmation is in the same place whatever you were looking
   at. It still announces through role="status" without taking focus. */
.contact__copy-status {
  position: fixed;
  left: 50%;
  bottom: var(--space-3);
  z-index: 200;
  margin: 0;
  padding: 16px 24px;
  max-width: calc(100vw - 2 * var(--gutter));
  background: var(--border-light);
  border-radius: 8px;
  color: var(--ink);
  font-size: var(--fs-label);
  /* Parked a full height below its resting place, so it travels up into
     view and back down out of it rather than fading on the spot. */
  transform: translate(-50%, calc(100% + var(--space-3)));
  opacity: 0;
  pointer-events: none;
  transition: transform var(--dur-mid) var(--ease-out),
              opacity var(--dur-mid) var(--ease-out);
}
.contact__copy-status.is-shown { opacity: 1; transform: translate(-50%, 0); }

@media (prefers-reduced-motion: reduce) {
  .contact__copy-icon { transition: none; }
  /* No travel under reduced motion — it appears and disappears in place. */
  .contact__copy-status { transition: none; transform: translate(-50%, 0); }
}

.contact__link {
  position: relative;
  isolation: isolate;
  font-family: var(--font-body);
  font-size: var(--fs-body);
  font-weight: var(--fw-body);
  padding-bottom: 0.15em;
}

/* The site's highlighter: a yellow bar sweeps in behind the text, left to
   right, the way a marker is drawn across a line. Behind, not over — yellow
   is 1.04:1 on this ground and could never carry the text itself. */
.contact__link::before,
.contact__social-text::before {
  content: "";
  position: absolute;
  z-index: -1;
  left: -0.14em;
  right: -0.14em;
  top: 0.08em;
  bottom: 0.12em;
  background: var(--yellow);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--dur-mid) var(--ease-out);
}
.contact__link:hover::before,
.contact__link:focus-visible::before,
.contact__social:hover .contact__social-text::before,
.contact__social:focus-visible .contact__social-text::before { transform: scaleX(1); }

@media (prefers-reduced-motion: reduce) {
  .contact__link::before,
  .contact__social-text::before { transition: none; }
}

/* ==========================================================================
   Footer
   ========================================================================== */

/* A light footer on the page's own ground. Everything in here used to be
   tuned for ink: the text, the mark and the hover were all chosen against a
   dark band, and each had to move with the background. */
.site-footer {
  background: var(--paper);
  color: var(--grey-600);
  padding-block: var(--space-4);
  border-top: 1px solid var(--rule-hairline);
}
.site-footer .container {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  font-size: var(--fs-label);
}
/* Not yellow: at 1.04:1 on this ground the hover would be invisible. */
.site-footer a:hover { color: var(--ink); }
/* Site-wide legal note: its own line under the row, not a fourth column. */
.site-footer__legal {
  /* On the same line as the rest of the row rather than a line of its own.
     order:-1 because it sits last in the markup — where it belongs for
     reading order — but reads first along the row. */
  order: -1;
  flex: 1 1 auto;
  margin: 0;
  font-size: var(--fs-micro);
  /* grey-400 is 2.6:1 here and would miss AA; grey-600 is 7.3:1. */
  color: var(--grey-600);
}
.footer-brand { display: flex; align-items: center; gap: 0.6rem; color: var(--ink); font-family: var(--font-display); }
.back-to-top {
  /* Only item left in the footer's top row now that the brand and LinkedIn
     moved into the contact section, so it is pushed to the right edge rather
     than relying on space-between, which has nothing to space it against. */
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  font-weight: var(--fw-semibold);
}
.back-to-top svg { width: 16px; height: 16px; }

/* ==========================================================================
   Case study pages
   ========================================================================== */

.cs-hero {
  padding-top: calc(var(--header-h) + var(--space-6));
  /* The hero's bottom edge is the first of the section seams, so it takes the
     same halving as the blocks below it. */
  padding-bottom: calc(var(--space-6) * 0.5);
  position: relative;
}
/* An opening image inside the hero, under the meta: it belongs to the title
   block rather than to the first section, so it sits above the hero's closing
   rule. Held well short of the column — this one is portrait, and at full
   width it would push the story itself off the first screen. */
.cs-hero__figure {
  margin-top: var(--space-6);
  /* In a class rather than inline, so a breakpoint can change it. */
  --fig-w: 56.7%;
}
@media (max-width: 700px) {
  /* Half again on a phone: the column is narrow enough that the desktop share
     leaves the screens too small to read. */
  .cs-hero__figure { --fig-w: 85%; }
}

/* The hero's closing rule — the one divider kept on a case study, marking where
   the title block ends and the story starts. Drawn to the width of the content
   column rather than the section box: the section spans the full width of main,
   so a plain border ran past the copy on both sides. */
.cs-hero::after,
.cs-block--ruled::before {
  content: "";
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: calc(min(var(--container), 100%) - 2 * var(--gutter));
  height: 1px;
  background: var(--border-light);
}
.cs-hero::after { bottom: 0; }
/* Opt-in rule above a block, for the few places a section needs to be set off
   from the one before it. The same line the hero closes with. */
.cs-block--ruled::before { top: 0; }

/* A part title: one step above the section heads, for grouping several sections
   under one banner. The section heads sit at h3, so this takes h2 — the only
   place on a case study that size is used below the hero. */
.cs-part-title {
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
  margin-bottom: var(--space-5);
}
.cs-hero__back {
  display: flex;
  width: fit-content;
  align-items: center;
  gap: 0.5em;
  font-size: var(--fs-small);
  font-weight: var(--fw-semibold);
  color: var(--grey-600);
  margin-bottom: var(--space-5);
}
.cs-hero__back:hover { color: var(--ink); }
/* Redundant once the header's own nav is on show: "Selected works" is already
   one click away up there. It only earns its place below 980px, where the nav
   collapses behind the toggle. */
@media (min-width: 981px) {
  .cs-hero__back { display: none; }
}
/* The "Case study 0N" label is numbering for the index, not information the
   reader needs once they are on the page. */
.cs-hero > .container > .eyebrow { display: none; }
.cs-hero__back svg { width: 16px; height: 16px; transition: transform var(--dur-fast) var(--ease-out); }
.cs-hero__back:hover svg { transform: translateX(-4px); }

.cs-hero__title {
  font-family: var(--font-display);
  /* The h1 token, the same one the homepage hero uses. */
  font-size: var(--fs-h1);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-display);
  line-height: var(--lh-display);
  margin-bottom: var(--space-5);
}

.cs-meta {
  display: grid;
  /* Two columns filled downwards, not across: the people run down the left, the
     project's own facts down the right. Placed explicitly rather than by flow,
     because the two columns are filled in the opposite order to the source. */
  /* 3:1, not even halves. The left column carries the roles and the team
     lists, which wrap; the right carries two short facts that never do, so an
     even split spent half the band on values a few words long. */
  grid-template-columns: 3fr 1fr;
  gap: var(--space-4) var(--space-5);
  /* No rule above the list: the hero's own divider already closes the block,
     and a second line this close read as a stray. */
  padding-top: var(--space-4);
}
/* Platform and Timeline — the project's own facts — take the right column.
   Everything about the people runs down the left, role first. */
.cs-meta > :nth-child(1) { grid-column: 2; grid-row: 1; }
.cs-meta > :nth-child(2) { grid-column: 2; grid-row: 2; }
.cs-meta > :nth-child(3) { grid-column: 1; grid-row: 1; }
.cs-meta > :nth-child(4) { grid-column: 1; grid-row: 2; }
.cs-meta > :nth-child(5) { grid-column: 1; grid-row: 3; }
.cs-meta > :nth-child(6) { grid-column: 1; grid-row: 4; }
@media (max-width: 700px) {
  .cs-meta { grid-template-columns: 1fr; }
  .cs-meta > * { grid-column: 1 !important; grid-row: auto !important; }
}
/* Label on top, value beneath it, both hard left. The value and its qualifier
   stay on one line together. */
.cs-meta > div {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.2em;
  justify-items: start;
  /* The cell stretches to the tallest item in its row, and without this the
     two auto rows inside share that extra height — dropping the value away
     from its own label. Pin both to the top. */
  align-content: start;
}
/* Body copy, not display bold: the label above already marks what each value
   is, so the value itself does not need the extra weight to be found. */
.cs-meta dd { font-size: var(--fs-body); font-weight: var(--fw-body); font-family: var(--font-body); }
.cs-meta dd small {
  font-size: var(--fs-body);
  font-family: var(--font-body);
  font-weight: var(--fw-body);
  color: var(--ink);
}
/* The qualifier sits on its own line under the value. No dash: with the two
   set at the same size and colour, a separator only added a mark to read. */
.cs-meta dd small { display: block; }
/* Value and qualifier as one run, divided by a dot, rather than stacked on two
   lines. The separator is drawn rather than typed so the markup stays two
   pieces of content and the dot cannot be read out as a word. */
.cs-meta dd.cs-meta__run small { display: inline; }
/* Spacing by margin, not by spaces inside content: a leading or trailing space
   in generated content collapses against the text beside it, which set the dot
   flush to the next word. Margins also leave the line free to wrap here. */
.cs-meta dd.cs-meta__run small::before {
  content: "\00b7";
  margin-inline: 0.45em;
}


/* Half the rhythm these blocks used to carry at every step. */
.cs-block { padding-block: calc(var(--space-7) * 0.5); position: relative; }
/* A framing question set in a card, so the beat that turns the problem into
   the brief is held apart from the copy either side of it. Same card as the
   testimonials — paper-alt on a hairline with the shared radius and shadow —
   because the site already has one card and should not grow a second. */
/* Leading its section rather than closing one: the top margin is there to
   part it from the copy above, and there is none. The section seam above it
   closes too — the question follows from the research it sits under, so it
   should not read as a fresh start. */
.cs-block > .container > .cs-hmw:first-child { margin-top: 0; }
.cs-block:has(> .container > .cs-hmw:first-child) { padding-top: 0; }
.cs-hmw {
  position: relative;
  margin-top: var(--space-6);
  /* Plus the 18px the yellow block hangs below the card: the margin measures
     from the card's own box, so without it the space you see under the block
     is 18px short of the space declared here. */
  margin-bottom: calc(var(--space-5) + 18px);
  padding: var(--space-4);
}
/* Yellow block behind the card, offset down and right by the same 18px the
   hero portrait's block uses going the other way. The card's own surface has
   to move off the element and onto ::after for this to work: a pseudo-element
   paints above its parent's background whatever its z-index, so a background
   declared here would cover the block rather than sit on it. */
.cs-hmw::before {
  content: "";
  position: absolute;
  inset: 0;
  transform: translate(18px, 18px);
  background: var(--yellow);
  border-radius: 14px;
}
.cs-hmw::after {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--paper-alt);
  border: 1px solid var(--border-light);
  border-radius: 14px;
  box-shadow: 0 1px 2px rgba(80, 66, 40, 0.04),
              0 8px 24px -12px rgba(80, 66, 40, 0.10);
}
/* Above both pseudo-elements — the same lift the hero portrait's image takes
   over its own yellow block. The card supplies the spacing, so the heading
   inside it sheds its own. */
.cs-hmw .cs-subhead { position: relative; z-index: 1; margin: 0; }

/* Subheading inside a long case-study section: one step below the section
   title, so a merged section reads as parts rather than one wall. */
.cs-subhead {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-tight);
  line-height: var(--lh-tight);
  margin-top: var(--space-6);
  margin-bottom: var(--space-3);
}
.cs-block > .container > .cs-subhead:first-child { margin-top: 0; }
/* A subhead that names the thing being shown rather than announcing a part of
   the story: body copy, so a long artefact name does not sit two steps above
   the prose it introduces. */
/* It leads the paragraph under it rather than heading a part, so it sits
   closer to that paragraph than a subhead would. Both sides are set: adjacent
   margins collapse to the larger of the two, so reducing only one changes
   nothing. */
.cs-subhead--plain { margin-top: var(--space-4); margin-bottom: var(--space-1); }
.cs-block > .container > .cs-subhead--plain + p,
.cs-block > .container > .cs-subhead--plain + .cs-numbered,
.cs-block > .container > .cs-subhead--plain + .cs-bullets { margin-top: var(--space-1); }
/* A list that completes the sentence above it — “what we observed:” — belongs
   with that sentence rather than a paragraph's distance from it. */
.cs-block > .container > p + .cs-bullets { margin-top: var(--space-1); }
/* A list standing on its own in the column is body copy, so it takes the body
   colour. The grey on .cs-bullets is for the nested case, where the list
   supports a point above it rather than being the content itself. */
.cs-block > .container > .cs-bullets { color: var(--ink); }
.cs-subhead--plain {
  font-family: var(--font-body);
  font-size: var(--fs-body);
  font-weight: var(--fw-body-bold);
  line-height: var(--lh-body);
  letter-spacing: normal;
}

/* Body copy sitting directly in a case-study block has no rhythm of its own —
   every other child here is a component that brings its own margins. One line
   of space above each, and above the lists and figures they sit between.
   Adjacent margins collapse, so nothing doubles up where two of these meet. */
.cs-block > .container > p,
.cs-block > .container > .cs-numbered,
.cs-block > .container > .cs-bullets { margin-top: var(--space-4); }
.cs-block > .container > :first-child { margin-top: 0; }
.cs-block:last-of-type::after { display: none; }
/* Label sits above its heading rather than beside it, so the two read as one
   stacked unit sharing a left edge. */
.cs-block__head {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2);
  margin-bottom: var(--space-5);
}
/* When the head is just an eyebrow and the heading-sized text sits outside it
   (a pull quote standing in for the h2), close the gap to the same space-2 an
   eyebrow normally has above its heading. */
.cs-block__head:not(:has(h2)) { margin-bottom: var(--space-2); }
/* A heading that carries a full sentence rather than a phrase — it runs the
   container instead of the shared 60% measure. */
.cs-block__head.cs-block__head--wide h2 { max-width: none; }
/* A section head followed straight by body copy sits closer to it — the 48px
   below a head is sized for a figure or a grid, not for a sentence that is
   still part of the same thought. The note's own 24px top margin collapses
   with this, so the gap is 24px rather than the sum. */
.cs-block__head:has(+ .cs-note) { margin-bottom: var(--space-3); }
/* A second heading partway down a block: it needs air above it to separate
   from the content it follows, and no doubled gap below against the figure
   margin that already sits there. */
.cs-block__head--mid { margin-top: var(--space-7); margin-bottom: 0; }

/* Standing body copy inside a block — sits at the container edge with the
   section titles, unlike a figcaption which hangs off the image. */
.cs-note {
  color: var(--ink);
  max-width: 60ch;
  margin-top: var(--space-3);
}
/* Sized at h3, not h2. It stays an <h2> — these are the page's top-level
   sections and the heading order has to hold — but a case study carries eight
   or nine of them, and at h2 each one landed as a page title of its own. */
.cs-block__head h2 {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  line-height: var(--lh-tight);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
}

/* One point per row, each marked by a bullet. The points are a list and now
   read as one: across columns they scanned as unrelated blocks, and with the
   numerals gone nothing tied a row together. */
.cs-numbered {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--list-gap);
}
/* The bullet. Drawn rather than a list marker, because these items are divs
   carrying a statement, a detail line and sometimes a nested list — the marker
   has to hang off the first line of the first paragraph whatever follows it.
   Items that lead with an icon keep the icon as their marker. */
.cs-numbered__item { position: relative; padding-left: 1.15em; }
.cs-numbered__item::before {
  content: "";
  position: absolute;
  left: 0;
  /* Optical centre of the first line: half the line box, less half the dot. */
  top: calc(0.5 * var(--lh-body-tight) * 1em - 3px);
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ink);
}
.cs-numbered__item:has(.cs-numbered__icon) { padding-left: 0; }
.cs-numbered__item:has(.cs-numbered__icon)::before { content: none; }
/* A block whose points are headings rather than list items: each one leads a
   passage of its own, so it drops its marker. Named on the block, not inferred
   from a nested list — a point can lose its list to an edit and should not
   grow a bullet back while its neighbours keep none. */
.cs-numbered--headed .cs-numbered__item { padding-left: 0; }
.cs-numbered--headed .cs-numbered__item::before { content: none; }
/* Two or three statements that each stand alone and are not being counted off
   against each other. The marker implies an enumeration the reader is meant to
   follow; without it they read as consecutive notes, which is what they are.
   Separate from --headed, where the marker goes because each point leads a
   passage rather than because the list is not a list. */
.cs-numbered--unmarked .cs-numbered__item { padding-left: 0; }
.cs-numbered--unmarked .cs-numbered__item::before { content: none; }
/* A small line glyph standing above a point, in place of its bullet. Inline
   SVG rather than an image: at this size a bitmap would need its own asset per
   point, and currentColor lets the mark invert with the section it sits in. */
.cs-numbered__item:has(.cs-numbered__glyph) { padding-left: 0; }
.cs-numbered__item:has(.cs-numbered__glyph)::before { content: none; }
.cs-numbered__glyph {
  display: block;
  width: 32px;
  height: 32px;
  margin-bottom: var(--space-2);
  /* A step lighter than the eyebrow's grey: the glyph introduces its point
     rather than saying anything itself, so it sits behind both the text and
     the label. Decorative, so the 2.5:1 it measures is not a text failure. */
  color: var(--grey-400);
  /* A shade heavier than the 1.75 the markup carries, so the line work holds
     its own against the body bold beside it. A CSS declaration outranks the
     presentation attribute on the element, so this governs every glyph. */
  stroke-width: 2;
}
.section--dark .cs-numbered__glyph,
.cs-impact .cs-numbered__glyph { color: var(--paper); }

/* A list whose points are led by an icon rather than a bullet, set in a single
   column: the glyph sits beside the text, spanning both the point and the line
   that supports it, rather than above it — six stacked icons would have made
   the list twice as tall as the prose it carries. */
/* Six short points read as a set rather than a queue: two per row once the
   column is wide enough to give each one a sentence-length measure. Rows take
   more than the shared list gap here — at 16px the vertical seam read tighter
   than the gutter beside it, and the grid lost its rows. */
@media (min-width: 700px) {
  .cs-numbered.cs-numbered--glyphs {
    grid-template-columns: repeat(2, 1fr);
    column-gap: var(--space-4);
    row-gap: var(--space-4);
  }
}
.cs-numbered--glyphs .cs-numbered__item {
  display: grid;
  grid-template-columns: 32px 1fr;
  column-gap: var(--space-3);
  align-items: start;
  /* Pack the rows to the top. Items in a grid row stretch to the tallest of
     them, and the surplus was being shared out between the point and its
     detail — opening this one to 21px while its neighbours sat at 8. */
  align-content: start;
}
/* Both paragraphs belong in the second track. Without this the detail line
   auto-placed into the first one and set a word per line inside 32px. */
.cs-numbered--glyphs .cs-numbered__item > p { grid-column: 2; }
/* Where a point was handed to the team that owns the fix, the destination is a
   labelled field rather than a sentence: three items sharing one prefix read
   as a handover log, which is the claim the section is making. */
/* The squad that owns the fix. A tag, not a status badge: it names a value,
   so it is static markup with no interactive affordance. The sentence above
   the list says these were handed over, so the tag needs no repeated label —
   three identical "ROUTED TO" lines were more noise than signal.
   Takes the work-card tag's own colour rather than --eyebrow-ink, which
   measures 3.73:1 on this fill and fails at label size. */
.cs-numbered__owner {
  /* The item is a grid, so a grid child blockifies — inline-block alone would
     still stretch the whole column. These two shrink the pill to its text. */
  justify-self: start;
  width: fit-content;
  /* One step down the scale: the tag belongs to the point above it, so it sits
     closer to its detail than the items sit to each other. */
  margin-top: var(--space-2);
  font-size: var(--fs-label);
  line-height: 1;
  padding: 7px 12px;
  border-radius: 999px;
  color: var(--grey-600);
  background: rgba(10, 10, 10, 0.05);
}
.cs-numbered--glyphs .cs-numbered__glyph {
  grid-column: 1;
  grid-row: 1 / -1;
  margin-bottom: 0;
  /* Centred on the first line of the point rather than its cap height. */
  margin-top: calc(0.5 * var(--lh-body-tight) * 1em - 16px);
}

.section--dark .cs-numbered__item::before,
.cs-impact .cs-numbered__item::before { background: var(--paper); }

/* Every numbered point reads the same, whatever block it sits in: body copy,
   with its supporting line below in the same size and weight but grey, so the
   two are told apart by colour rather than by weight. This used to be opt-in
   through a --lead modifier, so two identical markup patterns rendered at
   different sizes and weights. */
.cs-numbered__item > p:not(.cs-numbered__detail):not(.cs-numbered__owner) {
  font-family: var(--font-body);
  font-weight: var(--fw-body);
  /* Body size, not a step above it: in a four-up row the lead ran to five or
     six lines of two words. Weight and colour carry the hierarchy against the
     detail line beneath. */
  font-size: var(--fs-body);
  line-height: var(--lh-body-tight);
  color: var(--ink);
}
/* Icon above a numbered point — decorative, so alt is empty and it is skipped
   by screen readers; the sentence beneath already names the audience. */
.cs-numbered__icon {
  display: block;
  width: 128px;
  height: 128px;
  margin: 0;
}
/* Icon beside the words rather than above them. The icon spans both rows so
   the statement and its supporting line stack against it as one block. */
.cs-numbered__item:has(.cs-numbered__icon) {
  display: grid;
  grid-template-columns: auto 1fr;
  /* Tight to the icon: in a two-up the column is 387px and the icon takes a
     third of it, so every 8px of gutter comes off the measure. */
  column-gap: var(--space-2);
  align-items: start;
}
/* A third row that takes the slack. The icon is taller than the two lines of
   text beside it, and a item spanning auto rows hands its surplus height to
   those rows in equal shares — which opened ~25px between the statement and
   its explanation, from nothing either of them declared. The text rows stay
   auto, the empty row below absorbs the rest, and the pair packs to the top.
   (This replaced a subgrid that aligned the two columns' detail lines; the
   block runs as one column now, so there is nothing left to align across.) */
.cs-numbered__item:has(.cs-numbered__icon) { grid-template-rows: auto auto 1fr; }
.cs-numbered__icon { grid-row: 1 / -1; }
/* Shorter measure beside the icon — the column boundary stays put, only the
   text tightens. */
.cs-numbered__item:has(.cs-numbered__icon) p { max-width: 84%; }
/* Tighter than the default gap between a point and its explanation — beside
   an icon the pair should read as one block. */
/* Flush against the lead: beside an icon the pair already reads as one block,
   and the lead's own leading leaves enough air between the two lines. */
.cs-numbered__item:has(.cs-numbered__icon) .cs-numbered__detail { margin-top: 0; }
@media (max-width: 900px) {
  .cs-numbered__icon { width: 88px; height: 88px; }
  .cs-numbered__item:has(.cs-numbered__icon) { column-gap: var(--space-2); }
}

/* Bulleted evidence under a point. The global reset strips list markers, so
   they are restored here rather than assumed. Type matches the supporting
   line above it, so the two read as one block. */
.cs-bullets {
  list-style: disc;
  padding-left: 1.1em;
  margin-top: var(--space-2);
  font-family: var(--font-body);
  font-weight: var(--fw-regular);
  color: var(--grey-600);
}
.cs-bullets li + li { margin-top: var(--list-gap); }
.cs-bullets li::marker { color: var(--grey-400); }

/* Copy the case study still needs: a fact only the author can supply. Marked
   loudly and deliberately, so a draft can never be published mistaking a
   prompt for finished text. Remove the span along with the brackets. */
.cs-fillin {
  background: #FFF3C4;
  box-shadow: 0 0 0 1px #E8C547;
  border-radius: 3px;
  padding: 0 .25em;
  font-style: italic;
  color: #5C4A00;
}

/* A statement with its evidence on the line beneath it, the way a numbered
   point already works: the lead makes the claim, the detail backs it. The two
   sit at the same size, so weight and colour are what separate them. Used
   inside a bullet and as a standalone pair of paragraphs. */
.cs-lead {
  font-weight: var(--fw-body-bold);
  line-height: var(--lh-body-tight);
  color: var(--ink);
}
.cs-detail {
  font-weight: var(--fw-body);
  line-height: var(--lh-body-tight);
  color: var(--grey-600);
}
/* Tighter than the gap to the next item: a lead and its detail are one unit,
   and should read as closer to each other than to whatever follows. Written
   out at container depth too, because the block's own paragraph spacing is
   set there and would otherwise outrank a bare pair of classes. */
.cs-lead + .cs-detail,
.cs-block > .container > .cs-lead + .cs-detail { margin-top: var(--space-1); }

/* Inside a bullet the browser's paragraph margins have to go, or the pair
   sits looser than the gap between bullets. The reset is two elements deep,
   so the gap rule has to match that strength to survive it. */
/* A numbered point that carries more than one paragraph, or a list followed by
   the label for the next one: the 8px gap is for a detail line hanging off its
   lead, and at that distance a new label reads as another line of the thing
   above it. These get the paragraph gap instead. */
.cs-numbered__item > .cs-numbered__detail + .cs-numbered__detail,
.cs-numbered__item > .cs-bullets + .cs-numbered__detail { margin-top: var(--space-4); }
/* A second lead inside the same numbered point: it opens a new idea under the
   first one's evidence, so it takes the section-ish gap rather than sitting on
   whatever came before it with none at all. */
.cs-numbered__item > * + .cs-lead { margin-top: var(--space-6); }


.cs-bullets li > p { margin: 0; }
.cs-bullets li > .cs-lead + .cs-detail { margin-top: var(--space-1); }

/* Supporting line under a numbered point — regular weight so the point above
   it stays the thing you read first. */
/* Bold only where a supporting line follows it. A point that stands alone is
   body copy; a point with an explanation under it has to outrank that
   explanation, and the two sit at the same size — so the weight is what tells
   the lead from the detail. */
.cs-numbered__item:has(.cs-numbered__detail) > p:not(.cs-numbered__detail):not(.cs-numbered__owner) {
  font-weight: var(--fw-body-bold);
}
.cs-numbered__detail {
  font-family: var(--font-body);
  font-weight: var(--fw-body);
  /* Back to body: the rule above targets every p in the block, this one
     included, so the size has to be reset or the two lines match again. */
  font-size: var(--fs-body);
  line-height: var(--lh-body-tight);
  color: var(--grey-600);
  /* Tighter than the gap under the numeral: the lead line and its detail are
     one unit, and should read as closer to each other than to the marker. */
  margin-top: var(--space-1);
}

/* Card-sort insights: each prioritised insight carries the sub-finding it
   produced, so the pairing (and whether it was acted on) survives the layout —
   flattening these into sibling points loses which belongs to which. */
.cs-insights {
  list-style: none;
  margin-block: var(--space-5);
  margin-inline: 0;
  padding: 0;
  /* Narrower than the container: shortens the measure of both columns without
     touching anything else in the section. */
  max-width: 80%;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: var(--space-4);
}
.cs-insight {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2);
}
.cs-insight__lead {
  font-family: var(--font-display);
  /* Same token as the numbered point above: both are the headline line of a
     numbered point, so they stay in step with each other. */
  font-size: var(--fs-body);
  font-weight: var(--fw-body-bold);
  letter-spacing: var(--ls-tight);
  line-height: var(--lh-snug);
}
.cs-insight__sub {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.6em;
  align-items: start;
  margin-top: var(--space-2);
  /* The sub-finding under an insight's lead — the same relationship
     .cs-numbered__detail has to its point, so the same colour. */
  color: var(--grey-600);
  font-size: var(--fs-body);
}
/* The mark is a redundant cue on top of the wording, never the only one —
   and it is text, so it scales and inherits contrast rather than being a flat icon. */
.cs-insight__mark { font-weight: var(--fw-bold); line-height: var(--lh-body); }
.cs-insight__sub--yes .cs-insight__mark { color: #1F6F3D; }
.cs-insight__sub--no .cs-insight__mark { color: #A32020; }
@media (max-width: 780px) {
  .cs-insights { grid-template-columns: 1fr; }
}
@media (max-width: 780px) {
  .cs-numbered { grid-template-columns: 1fr; gap: var(--list-gap); }
}

/* Same type treatment as a section title, so the two read as one voice.
   Runs the full container width rather than a capped measure — the highlight
   is meant to span the column. */
.cs-pull {
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  line-height: var(--lh-tight);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
}
/* A pull quote is display type with no margin of its own, and the blocks that
   follow it — a split, a figure row — zero their own top margin so they sit
   tight inside a column. Where the two meet, the artwork started on the quote's
   last baseline with nothing between them. One line of space, from the scale. */
.cs-pull + .cs-split,
.cs-pull + .cs-figure,
.cs-pull + .cs-figure-row,
.cs-pull + .cs-boards { margin-top: var(--space-4); }
/* A figure after a split starts a new idea rather than continuing the one laid
   out beside the copy: the split ends on its own artwork, so the shared figure
   margin left the two reading as a single run of images. */
.cs-split + .cs-figure,
.cs-split + .cs-figure-row,
.cs-split + .cs-boards { margin-top: var(--space-6); }
/* Same measure as a section title, for a pull quote that should not run the
   full container. */
.cs-pull--measure { max-width: 60%; }
.cs-pull mark { background: var(--yellow); color: var(--ink); padding-inline: 0.1em; box-decoration-break: clone; -webkit-box-decoration-break: clone; }

/* Shrink-wraps the image so the caption's measure starts and ends with the
   photo rather than the container — captions read as belonging to the image. */
.cs-figure {
  margin-block: var(--space-5);
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
}
/* Lifts a figure off the page where the artwork has no edge of its own — a
   board or diagram exported flush, which otherwise floats. Same two-stage
   shadow family as the cards, just the resting stage and no hover, but carried
   further: a figure this size needs more than a card-sized shadow to lift at
   all. Roughly double the card's opacity, with the blur widened and the
   negative spread pulled back so the falloff is long and soft rather than a
   darker edge. The radius keeps the corners from catching a hard shadow. */
.cs-figure--lift img {
  border-radius: 10px;
  box-shadow: 0 2px 6px rgba(80, 66, 40, 0.05),
              0 16px 40px -14px rgba(80, 66, 40, 0.22);
}
.cs-figure img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: min(65vh, 680px);
  margin-inline: auto;
}
/* A video sits in a figure like an image does, and takes the same width rules
   — cs-figure sizes :is(img, svg) by name, so the element needs its own line
   rather than inheriting one. */
/* The device shot's width, as a share of the content column. Set here rather
   than inline so a breakpoint can change it: on a phone the column is narrow
   enough that 40% leaves the prototype too small to read. */
.cs-figure--proto { --fig-w: 40%; }
/* The shipped screens beside the prototype, on the same mechanism: in a class
   rather than inline, so a breakpoint can widen them on a phone. */
.cs-figure--shipped { --fig-w: 80%; }
/* Tablet: the column is wide enough that the devices dominated it, and the
   rail beside them left the page looking right-weighted. A fifth smaller,
   and shifted a fifth of their own width toward the rail. */
@media (min-width: 701px) and (max-width: 1024px) {
  .cs-figure--proto { --fig-w: 32%; }
  .cs-figure--shipped { --fig-w: 64%; }
  .cs-figure--proto,
  .cs-figure--shipped { transform: translateX(-20%); }
  /* Half the 72px row gap: the two devices read as one set, and at the
     smaller size the full seam left them stranded from each other. */
  /* Names two classes: .cs-split:has(.cs-numbered--stack) sets the same gap
     later in the file at equal specificity, and a single class lost to it. */
  .cs-split.cs-split--fill:has(.cs-figure--proto) { row-gap: calc(var(--space-6) * 0.5); }
}

@media (max-width: 700px) {
  /* Three fifths of the screen, not of the column — the device is the point
     of this figure on a phone, so it is sized against the viewport directly. */
  .cs-figure--proto { --fig-w: 60vw; }
  .cs-figure--shipped { --fig-w: 96%; }
  /* Half a seam further down than the 72px the split gives it, so the screens
     clear the points above them on a narrow column. Names the split and the
     base class: .cs-split .cs-figure zeroes margin-block later in the file,
     and a plain modifier lost to it. */
  .cs-split .cs-figure.cs-figure--shipped { margin-top: calc(var(--space-6) * 0.5); }
}

/* A real handset photograph around the prototype rather than a drawn bezel.
   The body PNG's screen is opaque, so it sits behind and the video is placed
   into the screen aperture on top of it; the lens then goes over the video,
   on the spot the body image has it. Every offset is a percentage of the
   device image, measured from the PNG itself, so the whole assembly scales
   with whatever width the figure is given. */
.cs-phone {
  display: block;
  position: relative;
  /* The device is drawn 1.8% taller than its photograph, which is what it
     takes for the screen aperture to hold the video's 786 x 1706 exactly.
     Stretching the frame rather than the footage: a 1.8% change to a bezel is
     invisible, the same change to a recorded UI is not. */
  aspect-ratio: 1302 / 2711.6;
}
.cs-phone__body {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: fill;
}
.cs-video {
  position: absolute;
  /* Screen aperture, measured in the body PNG: x 37–1252 of 1289,
     y 38–2630 of 2664. */
  /* A little inside the screen aperture, centred on it — the aperture
     itself is x 37-1252 of 1302, y 38-2630 of 2664 in the S22 body PNG. */
  left: 3.67%;
  top: 2.29%;
  width: 91.74%;
  height: 95.61%;
  /* Contain, not cover: the prototype's own status bar and nav bar are part of
     the recording, and cropping to fill the aperture cut them off. */
  object-fit: contain;
  /* The screen's own corner, measured off the body PNG: a 125px radius in a
     1216 x 2593 aperture. Written on both axes so the curve stays circular
     rather than stretching with the portrait box. */
  border-radius: 10.28% / 4.82%;
}
/* The play affordance while the prototype is idle: a disc in the middle of
   the screen, not a sheet over the whole of it — so the UI behind stays
   readable and only the button itself takes the click. */
.cs-phone__play {
  position: absolute;
  z-index: 3;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  display: grid;
  place-items: center;
  /* A share of the device rather than a fixed size, so the disc holds the same
     proportion at every width — 64px against the 324px desktop frame. The
     floor keeps it a 44px touch target on a phone, where the frame itself is
     only ~134px wide and 19.8% would be 27px. */
  width: clamp(44px, 19.8%, 72px);
  aspect-ratio: 1;
  height: auto;
  padding: 0;
  border: 0;
  border-radius: 50%;
  /* The .btn--fill pairing: ink ground, yellow mark, inverting on hover and
     press — the same gesture every button on the homepage makes. */
  background: var(--ink);
  color: var(--yellow);
  cursor: pointer;
  box-shadow: 0 2px 6px rgba(80, 66, 40, 0.18),
              0 12px 28px -12px rgba(80, 66, 40, 0.35);
  transition: background var(--dur-fast) var(--ease-out),
              color var(--dur-fast),
              transform var(--dur-fast) var(--ease-out),
              opacity var(--dur-fast) var(--ease-out);
}
.cs-phone__play svg {
  /* The same share of the disc the 41px mark held in the 64px button. */
  width: 64%;
  height: auto;
  aspect-ratio: 1;
  /* A triangle's visual centre sits left of its bounding box, so it is nudged
     right to look centred in the disc rather than measure centred. */
  margin-left: 3px;
}
.cs-phone__play:hover,
.cs-phone__play:focus-visible,
.cs-phone__play:active {
  background: var(--yellow);
  color: var(--ink);
  transform: translate(-50%, -50%) scale(1.06);
}
/* Once it is running the button steps out of the way entirely. */
.cs-phone.is-playing .cs-phone__play {
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .cs-phone__play,
  .cs-phone__play svg { transition: none; }
}
.cs-phone__camera {
  position: absolute;
  /* Centre of the cutout in the body PNG, and its diameter as a share of the
     device's width. */
  left: 49.39%;
  top: 4.15%;
  width: 4.27%;
  height: auto;
  transform: translate(-50%, -50%);
}
body:has(.cs-hero) .cs-figure.cs-figure--70 .cs-phone {
  width: var(--fig-w, 70%);
  margin-inline: auto;
}
/* The case-study figure sizes every img inside it to --fig-w. Both parts of
   the device are images, so they were being shrunk a second time inside the
   phone — the body to 40% of the frame and the lens to 40% of the device.
   The doubled class outweighs that rule, which is declared later in the file. */
body:has(.cs-hero) .cs-figure.cs-figure--70 .cs-phone__body.cs-phone__body {
  width: 100%;
  /* .cs-figure img sets height:auto and a max-height, which kept the body at
     its natural ratio — the frame stretched, the photograph inside it did not,
     and the video ran past the device's bottom edge. */
  height: 100%;
  max-height: none;
  margin-inline: 0;
}
/* Same trap as the device images: the figure rule sizes every svg inside it
   to --fig-w, which was squashing the play mark to 40% of the disc's width
   while leaving its height alone — a 26 x 41 triangle. */
body:has(.cs-hero) .cs-figure.cs-figure--70 .cs-phone__play svg {
  width: 64%;
  height: auto;
  margin-inline: 0;
}
body:has(.cs-hero) .cs-figure.cs-figure--70 .cs-phone__camera.cs-phone__camera {
  width: 4.27%;
  margin-inline: 0;
}

.cs-figure figcaption {
  /* One gap under every figure on the site, from the scale rather than a
     hand-set 24px — and nothing may stack a margin on top of it. */
  padding-top: var(--space-2);
  /* Centred under the artwork, not ranged left with the copy. A caption belongs
     to the image above it rather than to the text column, and most of these
     figures are wider than the measure, so a left-ranged caption read as a
     stray line of body copy that had lost its paragraph. Set here rather than
     per figure: the old --caption-center modifier had to be remembered, and
     half the figures on the site had been given it. */
  text-align: center;
  font-size: var(--fs-label);
  color: var(--grey-600);
  /* The figure shrink-wraps its widest child, so a long caption would widen the
     figure past the image. Zero width contributes nothing to that intrinsic
     sizing; the percentage min-width then fills whatever the image set. */
  width: 0;
  min-width: 100%;
}
/* One step down from the default figure cap, for diagrams that carry little
   detail and do not need the full height to be read. */
.cs-figure--sm img { max-height: min(49vh, 510px); }
/* A figure held to a share of the container rather than shrink-wrapping its
   image — overrides the fit-content width the base .cs-figure sets. */
.cs-figure--w70 {
  width: 56%;
  /* The standard figure gap. It used to carry twice that, from when this figure
     sat mid-section and had to close a thought on its own; it now ends a section,
     and the padding either side of the seam already supplies the break. */
  margin-bottom: var(--space-5);
}
/* A figure inside a --fill split that should not fill its column. The
   compound selector matches the fill rule's specificity and wins on order. */
.cs-split--fill .cs-figure.cs-figure--w80 { width: 80%; }
/* Stacked, the split has no column for the figure to sit in, so anything
   narrower than full width hangs on the left margin unless it is centred.
   Doubled class to outrank `.cs-split .cs-figure`, which zeroes the inline
   margins for the two-column case and is declared later in the file. */
.cs-split.cs-split--stacked > .cs-figure { margin-inline: auto; }
/* Breathing room inside the figure's own box, so the column width is unchanged
   and the artwork insets rather than the layout growing. */
.cs-figure--pad { padding: 40px; box-sizing: border-box; }
/* An image that opens in the zoom overlay without being cropped to a preview
   tile. .cs-board__btn crops to 4:3 and fades its foot, which suits a tall
   contact sheet; artwork that is already whole on the page only needs the
   control and the mark. Borrows .cs-board__hint for the disc, so the two read
   as the same affordance. */
.cs-zoomable {
  display: block;
  position: relative;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.cs-zoomable img {
  display: block;
  width: 100%;
  height: auto;
  transition: transform .4s cubic-bezier(.2,.7,.3,1);
}
.cs-zoomable:hover img,
.cs-zoomable:focus-visible img { transform: scale(1.01); }
/* Pulls a figure closer to the text it sits between. The artwork this is used
   on is exported with its own generous margin, so the figure's standard 48px
   reads as roughly double once the baked-in whitespace is counted. Halved here,
   and the block that follows gives up its own top margin, since margins collapse
   and the larger of the two would otherwise win back what this gives up. */
.cs-figure--tight { margin-block: var(--space-3); }
.cs-block > .container > .cs-figure--tight + * { margin-top: var(--space-3); }

/* A captioned figure meeting another figure needs more air than the standard
   48px seam. The caption is small grey text sitting right at the bottom edge,
   so at 48px it reads as a label on whatever comes next rather than a closing
   line on what it belongs to. Margins collapse, so the larger value wins. */
.cs-block > .container > .cs-figure:has(figcaption) + .cs-figure,
.cs-block > .container > .cs-figure:has(figcaption) + .cs-boards {
  margin-top: var(--space-6);
}

/* Same reasoning where a captioned figure is followed by a subheading: the
   subhead's own 32px top margin puts the new heading close enough to the
   caption to read as part of the figure it belongs to. This opens the seam so
   the caption closes its figure and the heading opens the next passage. */
.cs-block > .container > .cs-figure:has(figcaption) + .cs-subhead--plain {
  margin-top: var(--space-6);
}

/* The unmarked numbered block is prose standing in for body copy, and the
   figure under it is a full-width sheet. At the standard 48px the sheet
   crowds the last line; this gives the reader a beat before the diagram. */
.cs-block > .container > .cs-numbered--unmarked + .cs-figure {
  margin-top: var(--space-6);
}
/* One step down from full width, for a figure that does not need the whole
   column to be read. */
.cs-figure--step-down img,
.cs-figure-row--stack .cs-figure--step-down img { width: 75%; }
/* Lets the column width govern instead of the shared height cap. */
.cs-figure--tall img { max-height: min(88vh, 940px); }

/* Diagram and the points it illustrates, side by side — the points read as a
   stacked list beside the image rather than a row underneath it. */
.cs-split {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-6);
  align-items: center;
}
/* Left edge of the column, not centred in it — the image lines up with the
   section title above rather than floating. */
.cs-split .cs-figure { margin-block: 0; margin-inline: 0; }
/* Body copy sitting in a split column: no top margin (the row aligns it) and
   no 60ch cap, since the column is already the measure. */
.cs-split .cs-note { margin-top: 0; max-width: none; }
/* Several blocks stacked in one column of a split — the column is a single
   grid cell, so they need a wrapper of their own to sit in. */
.cs-colstack > * + * { margin-top: var(--space-4); }
.cs-colstack .cs-numbered--stack { gap: var(--space-3); }
/* Insights column beside the photo: one per row, using the full column. */
/* Image column carries more weight than the text column here — the card-sort
   photo needs the size to stay readable. */
.cs-split--image-first { grid-template-columns: 1.5fr 1fr; }
/* Centring is measured against the image alone: the caption is lifted out of
   flow so the figure's box is the image's box, and the column beside it lines
   up with the picture rather than picture-plus-caption. The row reserves room
   below for the caption it no longer counts. */
.cs-split--image-first:has(figcaption) { padding-bottom: 76px; }
.cs-split--image-first .cs-figure { position: relative; }
.cs-split--image-first figcaption { position: absolute; top: 100%; left: 0; right: 0; }
/* Both sides fill their columns, so the declared 60/40 and the 72px gutter are
   what you actually measure on screen. */
.cs-split--fill .cs-figure,
.cs-split--fill .cs-figure img { width: 100%; max-height: none; }
.cs-split--fill .cs-numbered--stack,
.cs-split--fill .cs-insights--stack,
.cs-split--fill .cs-aside { max-width: none; }

.cs-split--image-last { grid-template-columns: 1fr 1.5fr; }
/* Copy above the artwork rather than beside it. One column, and the text block
   drops the cap it carries for a narrow side-by-side cell — at full column
   width that cap would have left it ranged short of the image under it. */
.cs-split--stacked { grid-template-columns: 1fr; align-items: start; }
.cs-split--stacked .cs-aside,
.cs-split--stacked .cs-colstack { max-width: none; }
/* A wider image share still — for a photo whose detail (sticky notes, screens)
   only reads at size. */
/* Image column shrinks to the picture itself, so the 72px gutter is the gap you
   actually see — a fractional column left slack beside a height-capped image. */
.cs-split--image-lead { grid-template-columns: auto 1fr; }
/* Top-aligned by default — commentary beside a tall image reads better hung
   from the top than floated in the middle. Add --middle to centre it. */
.cs-split--image-last { align-items: start; }
.cs-split--middle { align-items: center; }

.cs-split .cs-insights--stack {
  grid-template-columns: 1fr;
  max-width: 80%;
  margin-block: 0;
  gap: var(--space-5);
}

/* Points that read as statements rather than running prose — same weight as
   the card-sort leads, so every list of findings on the page matches. */
/* Commentary column beside a figure: short headed passages, set at body size
   so the figure stays the loudest thing in the row. */
.cs-aside h3 {
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-body-bold);
  margin-bottom: var(--space-2);
}
.cs-aside { max-width: 60ch; }
.cs-aside p { color: var(--ink); }
.cs-aside p + h3 { margin-top: var(--space-5); }
/* The global reset zeroes p margins, so consecutive passages need their own —
   a full blank line here, so the conclusion reads as a separate beat. */
.cs-aside p + p { margin-top: var(--space-5); }

/* The labels take a shorter measure than the prose beneath them. */
.cs-numbered--trio .cs-numbered__item > p:not(.cs-numbered__detail):not(.cs-numbered__owner) { max-width: 80%; }
.cs-numbered--trio {
  /* A shorter measure than the full container. */
  max-width: 90%;
}
/* A point carrying its own bullet list is a passage, not a caption: a third of
   the width gives it a four-word measure. These stack one per row and take the
   full width, whatever column count the modifier asks for. */
.cs-numbered:has(.cs-bullets) {
  grid-template-columns: 1fr;
  gap: var(--space-6);
  max-width: none;
}
/* Full width is too long a measure for the prose itself, so the text inside
   each point keeps a readable one. */
.cs-numbered:has(.cs-bullets) .cs-numbered__item > p,
.cs-numbered:has(.cs-bullets) .cs-bullets {
  max-width: 62ch;
}
/* Closer than the gap a block of bulleted points takes. Each point here is a
   short passage under its own heading, so the headings do the separating and
   the air between them can come down. Doubled class to outweigh the :has()
   rule above, which still applies while any point keeps a list. */
.cs-numbered.cs-numbered--headed { gap: var(--space-6); }
/* A figure sitting among the points takes the grid's gap and nothing more.
   Its own block margin is for figures in flow; inside the grid that margin
   stacked on top of the gap and set it twice as far from the point above. */
.cs-numbered > .cs-figure { margin-block: 0; }
/* ...but it stays closer to the point it illustrates than to the next one, so
   it reads as that point's picture rather than as a divider between two.
   The negative top pulls its own seam back to --space-5 while the gap below
   it keeps the block's full --space-6. */
.cs-numbered--headed > .cs-figure {
  margin-top: calc(var(--space-5) - var(--space-6));
}
/* Two points side by side — the modifier's whole purpose, and the only block
   that opts out of the single column the rest of them take. */
/* Two points side by side: the shared list gap is a row value, and at 16px
   between columns the two blocks of prose ran together. The rows keep the
   token. */
.cs-numbered--pair { grid-template-columns: repeat(2, 1fr); column-gap: var(--space-4); }
@media (max-width: 780px) {
  .cs-numbered--pair { grid-template-columns: 1fr; }
}

/* Four points, one row. The shared auto-fit track needs 4x180px plus three
   --space-5 gaps — 864px against a column of 860 — so the fourth point dropped
   to a second row. Declaring the four tracks and a smaller gap keeps them on
   one line. */
.cs-numbered--grid2 {
  gap: var(--list-gap);
}

/* Three short statements that belong together, read as a set rather than a
   sequence: a row of equal tracks that falls back to fewer when the column
   can no longer give each one a readable measure. The glyph rules above
   already drop the bullet, so each point is led by its icon instead. */
.cs-numbered--row {
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  /* Rows take the shared list gap, which is what a phone sees once the three
     columns collapse; the columns keep their own wider one. */
  gap: var(--list-gap) var(--space-5);
}

.cs-numbered--stack {
  grid-template-columns: 1fr;
  gap: var(--space-4);
  /* Narrower than its column: a shorter measure beside the diagram, without
     moving the column boundary and pulling the image wider. */
  max-width: 80%;
}
/* Numbered points beside an image: the column is too narrow a measure for the
   point's display type, so each point broke to two or three words a line. The
   split becomes one column instead — the points run as a row across the full
   width, and the image sits beneath them at the width of the content. */
.cs-split:has(.cs-numbered--stack) {
  grid-template-columns: 1fr;
  gap: var(--space-6);
  align-items: start;
  /* The caption is back in flow here, so the row no longer reserves space. */
  padding-bottom: 0;
}
/* Whatever the split's modifier says about order, the image follows the points. */
.cs-split:has(.cs-numbered--stack) > figure { order: 2; }
.cs-split:has(.cs-numbered--stack) .cs-numbered--stack {
  max-width: none;
  /* Beside an image the points run across the row rather than down a column —
     the split has already become one column, so there is full width to use. */
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  /* Rows take the shared list gap; the columns keep their own wider one. */
  gap: var(--list-gap) var(--space-5);
}
/* Four points break 3 + 1 on a three-track row, which reads as a mistake.
   Two rows of two keeps the set square. */
.cs-split:has(.cs-numbered--stack) .cs-numbered--stack:has(> :nth-child(4):last-child) {
  grid-template-columns: repeat(2, 1fr);
}
/* Three points break 2 + 1 for the same reason: at this width three 200px
   tracks plus the column gap overrun the row, so auto-fit drops to two. Naming
   the count holds the set in one row and narrows the gap to pay for it. Sits
   excludes the opt-out below by name: :has() takes the specificity of its
   argument, so this selector outranks .cs-numbered--column and source order
   cannot settle it. */
.cs-split:has(.cs-numbered--stack) .cs-numbered--stack:not(.cs-numbered--column):has(> :nth-child(3):last-child) {
  grid-template-columns: repeat(3, 1fr);
  column-gap: var(--space-4);
}
/* Opt back out of the row, for a set whose points are long enough that three
   narrow columns would break every line to two or three words. Specific enough
   to beat both rules above, which is why it names the base class as well. */
.cs-split:has(.cs-numbered--stack) .cs-numbered--stack.cs-numbered--column {
  grid-template-columns: 1fr;
  gap: var(--list-gap);
}
.cs-split:has(.cs-numbered--stack) .cs-figure,
.cs-split:has(.cs-numbered--stack) .cs-figure.cs-figure--w80 {
  position: static;
  width: 100%;
}
.cs-split:has(.cs-numbered--stack) .cs-figure img {
  width: 100%;
  /* Height caps were there to keep an image from dominating its column; across
     the full width the aspect ratio is the only constraint that matters. */
  max-height: none;
  height: auto;
}
.cs-split:has(.cs-numbered--stack) figcaption {
  position: static;
  margin-top: 0;
}

/* Insights beside a photo: the photo lost too much width to the column beside
   it. The split becomes one column — the insights as a row, the photo beneath
   them at full width. */
.cs-split:has(.cs-insights--stack) > figure { order: 2; }
.cs-split:has(.cs-insights--stack) {
  grid-template-columns: 1fr;
  gap: var(--space-6);
  align-items: start;
  padding-bottom: 0;
}
.cs-split:has(.cs-insights--stack) .cs-figure,
.cs-split:has(.cs-insights--stack) .cs-figure img { position: static; width: 100%; }
.cs-split:has(.cs-insights--stack) figcaption {
  position: static;
  /* The base caption already carries 24px of top padding; this margin stacked
     a second gap on it, so the line hung 40px under the image here and 24px
     everywhere else. Taking the caption out of absolute flow is all this rule
     needs to do. */
  margin-top: 0;
}
.cs-split:has(.cs-insights--stack) .cs-insights--stack {
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--space-5);
  max-width: none;
}

@media (max-width: 900px) {
  .cs-split { grid-template-columns: 1fr; gap: var(--space-5); }
}

.cs-figure-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-4);
}
/* One above the other, each at full width — for diagrams too detailed to read
   at half the container. */
.cs-figure-row--stack { grid-template-columns: 1fr; gap: var(--space-5); }
.cs-figure-row--stack .cs-figure { width: 100%; }
/* Container width governs, not the shared height cap — these diagrams are read,
   not glanced at. */
.cs-figure-row--stack img { width: 100%; max-height: none; }
@media (max-width: 780px) {
  .cs-figure-row { grid-template-columns: 1fr; }
}

/* Evidence pairs, built to the portfolio layout: a light finding panel with a
   dark pull-quote overlapping its lower-right, so each quote reads as evidence
   attached to the finding above it rather than a separate card. Greys are the
   site's own paper/ink tokens, not the source deck's. */
.ev-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-6) var(--space-5);
}
.ev-item { position: relative; }
.ev-finding {
  background: var(--paper-alt);
  border-radius: 14px;
  padding: var(--space-4);
  /* Stops short of the item's right edge so the quote below can sit under the
     gap and still overlap the panel. */
  margin-right: 12%;
}
/* Same treatment as a subhead: the finding is the statement, the quote below
   is its supporting evidence. */
.ev-finding p {
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-body-bold);
  letter-spacing: var(--ls-tight);
  line-height: var(--lh-snug);
}
.ev-stat {
  margin-top: 0.35em;
  font-size: var(--fs-small);
  color: var(--grey-600);
}
.ev-quote {
  position: relative;
  margin: -18px 0 0 22%;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.55em;
  background: var(--border-dark);
  color: var(--paper);
  border-radius: 14px;
  padding: var(--space-3) var(--space-4);
  font-size: var(--fs-body);
  line-height: var(--lh-body-tight);
}
.ev-quote__mark {
  font-family: var(--font-display);
  font-style: normal;
  font-weight: var(--fw-bold);
  font-size: var(--fs-h3);
  line-height: var(--lh-flat);
  color: var(--paper);
}
.ev-quote p { margin: 0; }
.ev-quote cite {
  /* Sits in the text column, not under the quote mark — otherwise it forms a
     third grid item in column one and squeezes the quote. */
  grid-column: 2;
  display: block;
  margin-top: 0.35em;
  color: var(--grey-400);
}
@media (max-width: 780px) {
  .ev-grid { grid-template-columns: 1fr; gap: var(--space-5); }
  .ev-finding { margin-right: 0; }
  .ev-quote { margin-left: 8%; }
}

.cs-quote-grid {
  display: grid;
  /* Auto-fitting, so the count of evidence cards is set by the research,
     not by the grid. */
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--space-4);
}
.cs-quote-card {
  background: var(--paper-alt);
  padding: var(--space-4);
  border: 1px solid var(--border-light);
}
.cs-quote-card .stat { font-family: var(--font-display); font-size: var(--fs-lead); font-weight: var(--fw-bold); margin-bottom: var(--space-2); }
.cs-quote-card blockquote { color: var(--ink); margin: var(--space-2) 0 0; }
.cs-quote-card cite { display: block; margin-top: var(--space-2); font-size: var(--fs-label); color: var(--grey-600); font-style: normal; }
@media (max-width: 780px) {
  .cs-quote-grid { grid-template-columns: 1fr; }
}

/* Tablet: the splits have collapsed to one column, so these lists have the full
   width back — run their points across it instead of down a tall column. */
@media (min-width: 601px) and (max-width: 1024px) {
  .cs-insights--stack,
  .cs-split .cs-insights--stack { grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-4); }
}

/* Narrow screens are read by scrolling — the desktop section rhythm leaves too
   much dead space between blocks, so tighten it in two steps. */
@media (max-width: 1024px) {
  .cs-block { padding-block: calc(var(--space-6) * 0.5); }
}
@media (max-width: 600px) {
  .cs-block { padding-block: calc(var(--space-5) * 0.5); }
}

/* Mobile and tablet: the measure caps that shape the desktop layout only waste
   space on a narrow screen — text runs the full container instead. */
@media (max-width: 1024px) {
  .cs-block__head h2,
  .cs-aside,
  .cs-insights,
  .cs-split .cs-insights--stack,
  .cs-numbered--stack,
  .cs-pull { max-width: none; }
}

.cs-impact { background: var(--ink); color: var(--paper); }
.cs-impact .cs-block__head h2 { color: var(--paper); max-width: none; }
.cs-impact-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--space-5);
}
@media (max-width: 1024px) {
  .cs-impact-stats { gap: var(--space-3); }
}
.cs-impact-stats b {
  display: block;
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  line-height: var(--lh-h2);
  color: var(--yellow);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
}
/* Captions, not prose: two short lines under a figure, so they set tighter
   than body copy — --lh-body opened a gap wide enough to read as two separate
   lines rather than one wrapped caption. */
.cs-impact-stats span {
  color: var(--grey-400);
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  display: block;
}
/* The padding here was a second gap stacked on the margin — 80px in all above
   the first outcome, with nothing drawn between the two blocks to justify it.
   The margin alone separates the numbers from the outcomes they explain. */
.cs-impact ul.cs-impact-list { margin-top: var(--space-5); padding-top: 0; }
/* Same drawn bullet as the numbered points, in paper against the ink section.
   The list is reset globally, so the marker is its own box rather than a
   list-style — which also lets it sit on the optical centre of the first line
   when an outcome wraps. */
.cs-impact-list li { position: relative; padding-left: 1.15em; }
.cs-impact-list li + li { margin-top: var(--list-gap); }
.cs-impact-list li::before {
  content: "";
  position: absolute;
  left: 0;
  top: calc(0.5 * var(--lh-body) * 1em - 3px);
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--paper);
}

/* Both ends of the same row: the study before this one on the left, the one
   after on the right, so a reader can move either way without returning to
   the index. One container holds the pair, which means the row takes the
   content column's own insets — the back link starts where the body copy
   starts and the forward link ends where it ends. */
.cs-pager {
  /* Half a section gap: the cards carry their own padding, so the full
     one left the row floating away from the study it closes. */
  padding-top: calc(var(--section-gap) * 0.5);
}
.cs-pager .container {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-4);
  /* Stretch rather than start: the two cards hold the same box however many
     lines their titles take. */
  align-items: stretch;
}
/* The site's one card treatment — the same surface, hairline and warm
   two-stage shadow the testimonials, the work previews and the HMW card all
   take. The gap above belongs to the row now, not to either link, so the
   padding here is the card's own. */
.cs-pager .cs-next {
  padding: var(--space-4);
  background: var(--paper-alt);
  border: 1px solid var(--border-light);
  border-radius: 14px;
  box-shadow: 0 1px 2px rgba(80, 66, 40, 0.04),
              0 8px 24px -12px rgba(80, 66, 40, 0.10);
  transition: box-shadow var(--dur-fast) var(--ease-out);
}
/* The card lifts on the same shadow family rather than a new one: both stages
   deepen together, so it rises instead of darkening. */
.cs-pager .cs-next:hover,
.cs-pager .cs-next:focus-visible {
  box-shadow: 0 2px 4px rgba(80, 66, 40, 0.06),
              0 14px 32px -14px rgba(80, 66, 40, 0.16);
}
/* It points back, so it ranges off the left edge and its arrow follows.
   Names the base class too: .cs-next is declared below this and sets the
   opposite alignment at the same specificity, where source order decides. */
.cs-next.cs-next--prev { text-align: left; }
@media (max-width: 700px) {
  .cs-pager .container {
    grid-template-columns: 1fr;
    gap: var(--space-3);
  }
  /* Same collision as the tablet rule below: the hand reaches up out of the
     contact section and lands on the lower card. */
  .cs-pager { padding-bottom: var(--space-5); }
}
/* Between the phone stack and the desktop row, the contact section's hand
   reaches up out of its own padding and crowds the cards — it is positioned
   against that section, not against the flow, so only space below the row
   moves it clear. */
@media (min-width: 701px) and (max-width: 1024px) {
  .cs-pager { padding-bottom: var(--space-6); }
}

.cs-next {
  display: block;
  /* The section-gap token rather than a larger one of its own: this is a seam
     between sections like any other, and it sat twice as open as the rest.
     Only on top: the contact section below brings its own matching padding,
     so a bottom one here made that single seam twice as open again. */
  padding-top: var(--section-gap);
  padding-bottom: 0;
  /* Ranged right: it points forward, to the next case study, and the arrow now
     ends on the same edge the content column does. */
  text-align: right;
  position: relative;
}
.cs-next__label { color: var(--eyebrow-ink); display: block; margin-bottom: var(--space-2); }
/* The title is ink on paper; a colour hover had nowhere to go. Gold measured
   1.1:1 on the page and yellow 1.02:1, so either made the title vanish on
   hover rather than highlight it. The accent goes behind the text instead —
   ink on yellow is 16.5:1 — and sweeps in like a marker.

   Set inline rather than block so the highlight tracks the words: each line
   box gets its own band (box-decoration-break), instead of one rectangle the
   width of the container. */
.cs-next__title {
  display: inline;
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  /* The same leading the section heads and subheads take at this size — it was
     inheriting the body's, which set the wrapped lines nearly half a line
     further apart than any other heading on the page. */
  line-height: var(--lh-tight);
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-heading);
  padding: 0.04em 0.1em;
  background-image: linear-gradient(var(--yellow), var(--yellow));
  background-repeat: no-repeat;
  background-position: left center;
  background-size: 0% 100%;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  transition: background-size var(--dur-mid) var(--ease-out);
}
.cs-next:hover .cs-next__title,
.cs-next:focus-visible .cs-next__title {
  background-size: 100% 100%;
}
/* The arrow sits on its own line under the title rather than trailing the last
   word. It is out of the title element so the hover sweep stays on the words,
   and aria-hidden because the link already reads as the case study's name. */
.cs-next__arrow {
  display: block;
  margin-top: var(--space-2);
  font-size: var(--fs-h3);
  line-height: 1;
  /* The arrow travels the way it points, which is the whole affordance these
     links get — no border, no box, just the mark moving off in the direction
     the click will take you. Fast rather than mid: the title's highlight is
     the slow gesture, and the arrow should arrive before it finishes. */
  transition: transform var(--dur-fast) var(--ease-out);
}
.cs-next:hover .cs-next__arrow,
.cs-next:focus-visible .cs-next__arrow { transform: translateX(6px); }
.cs-next--prev:hover .cs-next__arrow,
.cs-next--prev:focus-visible .cs-next__arrow { transform: translateX(-6px); }
@media (prefers-reduced-motion: reduce) {
  .cs-next__title,
  .cs-next__arrow,
  .work-card__title-mark { transition: none; }
}

/* ==========================================================================
   Utility
   ========================================================================== */

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

.text-accent { color: var(--yellow); -webkit-text-stroke: 1px var(--ink); }

/* ==========================================================================
   Case-study section rail
   A fixed index of the page's sections down the left margin. The layout has
   no room for a permanently labelled list at container width, so it rests as
   a column of dashes: the active section keeps its name, and hovering or
   focusing the rail reveals the rest.
   ========================================================================== */
.cs-rail {
  position: fixed;
  /* Left edge of the content band itself, not of the viewport, so the rail
     sits inside the same column as the copy rather than in the page margin. */
  left: max(var(--container-pad), calc((100vw - var(--container)) / 2 + var(--container-pad)));
  width: var(--cs-rail-col);
  /* Top-aligned with the first line of the content column rather than centred
     on the viewport, so the two columns start on the same line. Mirrors the
     case-study hero's own top padding. */
  top: calc(var(--header-h) + var(--space-6));
  z-index: 60;
  /* Only the links take pointer events, so the rail never blocks the page
     it floats over. */
  pointer-events: none;
}
.cs-rail__title {
  /* Carries the .eyebrow class, so it takes that treatment whole — size,
     weight, tracking and colour. Nothing to override but its position. */
  /* Flush with the rule the list hangs off, not with the labels: the title
     heads the whole column. */
  margin: 0 0 var(--space-3);
}
.cs-rail ol {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  /* A continuous hairline runs the height of the list; the item being read
     marks its own segment of it. */
  border-left: 1px solid var(--border-light);
}
.cs-rail li { position: relative; }
.cs-rail a {
  pointer-events: auto;
  display: block;
  padding: 9px 0 9px 18px;
  text-decoration: none;
  color: var(--grey-600);
  font-size: var(--fs-small);
  letter-spacing: normal;
  line-height: var(--lh-snug);
  transition: color var(--dur-mid) var(--ease-out);
}
/* The marker sits on the rule itself rather than beside it, so the line reads
   as one continuous thing with the current section picked out of it. */
.cs-rail li::before {
  content: "";
  position: absolute;
  left: -1px;
  top: 0;
  bottom: 0;
  width: 2px;
  background: var(--ink);
  opacity: 0;
  transition: opacity var(--dur-mid) var(--ease-out);
}
.cs-rail li:has(a.is-current)::before { opacity: 1; }
.cs-rail a.is-current { color: var(--ink); font-weight: var(--fw-body-bold); }
.cs-rail a:hover, .cs-rail a:focus-visible { color: var(--ink); }

/* Same highlighter as the contact link and the next-case title. It rides on
   the label rather than the anchor so it tracks the words, not the row — the
   longer labels wrap, and each line box takes its own band. */
.cs-rail__label {
  padding: 0.1em 0.2em;
  margin-left: -0.2em;
  background-image: linear-gradient(var(--yellow), var(--yellow));
  background-repeat: no-repeat;
  background-position: left center;
  background-size: 0% 100%;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  transition: background-size var(--dur-mid) var(--ease-out);
}
.cs-rail a:hover .cs-rail__label,
.cs-rail a:focus-visible .cs-rail__label { background-size: 100% 100%; }
/* Ink on the yellow in both grounds — paper would be 1.07:1 on it. */
.cs-rail a:hover .cs-rail__label,
.cs-rail a:focus-visible .cs-rail__label,
.cs-rail li.is-on-dark a:hover .cs-rail__label,
.cs-rail li.is-on-dark a:focus-visible .cs-rail__label { color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
  .cs-rail__label { transition: none; }
}

/* Rail over a dark section. Measured contrast on #0A0A0A: --grey-400 7.6:1 for
   the resting labels (--grey-600 would be 3.5:1, under the 4.5 floor for text
   this size) and --paper 18.3:1 for the one being read. */
/* Inverted per item rather than for the whole rail: only the items actually
   sitting over a dark section flip. */
.cs-rail li.is-on-dark a { color: var(--grey-400); }
.cs-rail li.is-on-dark::before { background: var(--paper); }
.cs-rail li.is-on-dark a.is-current { color: var(--paper); }
.cs-rail li.is-on-dark a:hover,
.cs-rail li.is-on-dark a:focus-visible { color: var(--paper); }
.cs-rail--on-dark ol { border-left-color: var(--border-dark); }

/* The rail and the copy share the homepage's content band: the band's outer
   edges stay where they are on every other page, and the rail takes its column
   from the inside, pushing the copy right rather than widening the page. So the
   two together span the same band the homepage does at a wide viewport.
   Scoped with :has() so only case-study pages are affected, and only where the
   band is wide enough to give up a column. */
/* Case studies run a wider band than the homepage: the rail takes a column out
   of it, so at the shared 1440 the copy ended up narrower than the homepage's
   own measure. 10% more container gives it back without touching the split. */
body:has(.cs-hero) {
  --container: 1296px;
  /* --cs-band is declared on :root, so it resolved against the 1440 there and
     kept doing so after the container grew: every percentage derived from it
     then described a band narrower than the one on screen, and the dividers
     sat inset from the copy by the difference. Redeclared here so it resolves
     against this container. */
  --cs-band: calc(min(var(--container), 100vw) - 2 * var(--gutter));
}

@media (min-width: 1100px) {
  /* The content is centred in the page, not ranged beside the rail. Both sides
     take the same inset, and that inset is whatever it takes to clear the rail
     on the left: the shell's own padding, the rail's column, and a gutter. The
     right side has nothing to clear, but matching it is what puts the content
     on the page's centre line rather than off to one side of it.

     Deriving the inset from the rail rather than fixing it means the column
     narrows as the viewport does, so the clearance holds at every width down to
     1100 instead of the rail eventually landing on the copy. */
  body:has(.cs-hero) main .container {
    --cs-inset: calc(var(--container-pad) + var(--cs-rail-col) + 40px);
    padding-left: var(--cs-inset);
    padding-right: var(--cs-inset);
  }
  /* Reading text runs a shorter measure than the column it sits in: at the full
     width a line of body copy passed 100 characters, well beyond the 65–75 that
     reads comfortably. Artwork is exempt — a figure, a board pair or a split
     still spans the whole column, so the images keep the width the headings and
     dividers are set to and the copy indents within it. Stated as a cap on the
     children rather than on .container, because narrowing the container itself
     would take the figures with it.

     The measure is a length, not a percentage, so the same declaration can be
     used on text nested inside a narrower layout: a 360px column in a two-up
     split is already under it and is left alone, where `80%` would have taken
     a fifth off that column too. */
  body:has(.cs-hero) {
    --cs-col: calc(min(var(--container), 100vw)
                 - 2 * (var(--container-pad) + var(--cs-rail-col) + 40px));
    --cs-measure: calc(0.8 * var(--cs-col));
  }
  body:has(.cs-hero) main .container > * {
    max-width: var(--cs-measure);
    /* Centred in the column, so the text block sits inside the artwork's width
       with equal slack either side. This only holds together because the caps
       underneath are switched off: several of these components carry a narrower
       measure of their own, and centring those made a heading, its paragraph and
       the list beneath it each settle on a different left edge. */
    margin-inline: auto;
  }
  /* Every text block in the column is exactly the measure, not its own fraction
     of it. The component caps were set when each block sat in the full column
     and needed shortening on its own; the column is already the short measure
     here, so they now only break the shared edge. */
  body:has(.cs-hero) main .container > .cs-bullets,
  body:has(.cs-hero) main .container > .cs-note,
  body:has(.cs-hero) main .container > .cs-numbered--stack,
  body:has(.cs-hero) main .container > .cs-numbered--trio,
  body:has(.cs-hero) main .container > .cs-insights { max-width: var(--cs-measure); }
  /* Inside those blocks the caps come off entirely: the block is already the
     measure, so a further fraction of it only shortens one row against its
     neighbours. */
  body:has(.cs-hero) main .container .cs-numbered__item > p,
  body:has(.cs-hero) main .container .cs-insights li,
  body:has(.cs-hero) main .container > .cs-split .cs-aside { max-width: none; }
  body:has(.cs-hero) main .container > figure,
  body:has(.cs-hero) main .container > .cs-boards,
  body:has(.cs-hero) main .container > .cs-figure-row,
  /* The how-might-we card is a panel, not a paragraph — it carries a block of
     colour and a rule behind it, and at the copy measure it read as a floating
     note rather than the section's opening statement. */
  body:has(.cs-hero) main .container > .cs-hmw,
  /* The closing section is the homepage's, borrowed whole, so it keeps the
     homepage's width rather than the case study's measure. */
  body:has(.cs-hero) main #contact .container > * { max-width: none; }
  /* A split takes the measure like any other content block: its text and its
     artwork scale together inside it, so the two-up layouts keep their
     proportions and simply sit in the narrower column. */
  body:has(.cs-hero) main .container > .cs-split > :not(figure) {
    margin-inline: auto;
  }
  /* A figure that opts back into the copy's measure. Artwork is exempt from the
     cap by default because most of these are wide sheets that need the column,
     but a diagram already set at a comfortable reading size only looks oversized
     at that width. Named per figure rather than inferred, so the exemption stays
     the rule and this stays the exception. */
  body:has(.cs-hero) main .container > .cs-figure--measure { max-width: var(--cs-measure); }
  /* Three points read across rather than down, held to the copy measure like
     everything else in the column. auto-fit cannot place three tracks in that
     width at the row's own column gap, so the count is stated and the gap
     tightened — the alternative was letting the third point wrap to a second
     row, which is the comparison this block exists to avoid. */
  body:has(.cs-hero) main .container > .cs-numbered--row {
    grid-template-columns: repeat(3, 1fr);
    column-gap: var(--space-4);
  }
  /* Same reasoning for the impact figures: three numbers compared at a glance,
     held to the copy measure, which auto-fit cannot fill at the wider gap. */
  body:has(.cs-hero) main .container > .cs-impact-stats {
    grid-template-columns: repeat(3, 1fr);
    column-gap: var(--space-4);
  }
  /* The rule runs the content column. Both are centred on the section now, so
     the offset that used to push it right past the rail's indent is gone. */
  body:has(.cs-hero) .cs-hero::after,
  body:has(.cs-hero) .cs-block--ruled::before {
    width: var(--cs-col);
    transform: translateX(-50%);
  }
  /* The closing section is the shared one from the homepage. It sits inside
     main, so it was inheriting the rail's indent above and running narrower
     here than on the homepage. It belongs to neither the rail nor the copy
     column, so it takes the standard container inset instead. */
  body:has(.cs-hero) main #contact .container {
    padding-left: var(--container-pad);
    padding-right: var(--container-pad);
  }
}
/* The copy column is already the measure on a case study — it is a fraction of
   the band, not the full page. The percentage caps underneath were set when the
   column was the whole container, and now just leave a ragged right edge that
   stops short of the titles and dividers. Everything runs the column's width. */
body:has(.cs-hero) .cs-numbered--trio,
body:has(.cs-hero) .cs-numbered--stack,
body:has(.cs-hero) .cs-insights,
body:has(.cs-hero) .cs-split .cs-insights--stack { max-width: none; }
body:has(.cs-hero) .cs-numbered--trio .cs-numbered__item > p:not(.cs-numbered__detail):not(.cs-numbered__owner),
body:has(.cs-hero) .cs-numbered__item:has(.cs-numbered__icon) p { max-width: 92%; }
/* Figures sized themselves to the picture and centred the slack. On a case
   study they fill the column instead, so an image's edges line up with the
   text above and below it. */
body:has(.cs-hero) .cs-figure { width: 100%; }
body:has(.cs-hero) .cs-figure img,
body:has(.cs-hero) .cs-figure--step-down img {
  width: 100%;
  max-height: none;
}
/* A figure that reads better below full width — a fraction of the column,
   centred. The fraction is a variable so a single figure can take its own
   without earning a class of its own; 70% is the default. Doubled class so it
   outweighs both the rule above and the split rule that fills the column. */
body:has(.cs-hero) .cs-figure.cs-figure--70 :is(img, svg) {
  width: var(--fig-w, 70%);
  margin-inline: auto;
}
/* The caption belongs to the picture, so it takes the picture's width rather
   than the column's — otherwise a short caption under a narrowed figure runs
   wider than the thing it describes and stops reading as attached to it. */
body:has(.cs-hero) .cs-figure.cs-figure--70 > figcaption {
  width: var(--fig-w, 70%);
  /* The base caption sets min-width: 100% so a caption always fills whatever
     the image's intrinsic width turned out to be; here the width is known, so
     that floor has to come off or it wins. */
  min-width: 0;
  margin-inline: auto;
}

/* A drawn figure rather than a photograph: it takes currentColor, so it reads
   as ink on paper here and would invert with any dark section it was moved
   into, and its labels set in the site's own face rather than the browser's. */
.cs-showdown {
  display: block;
  height: auto;
  color: var(--ink);
}
.section--dark .cs-showdown,
.cs-impact .cs-showdown { color: var(--paper); }
.cs-showdown text {
  fill: currentColor;
  stroke: none;
  font-family: var(--font-body);
}
.cs-showdown__kicker {
  font-size: 21px;
  font-weight: var(--fw-semibold);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
}
.cs-showdown__name {
  font-size: 28px;
  font-weight: var(--fw-body-bold);
}
.cs-showdown__name--lead { font-size: 38px; }
.cs-showdown__role {
  font-size: 21px;
  opacity: 0.62;
}
/* The 40px inset on a padded figure was horizontal breathing room back when the
   figure sized itself to the picture. Filling the column, it only holds the
   image short of the text's edges, so it keeps the vertical half. */
body:has(.cs-hero) .cs-figure--pad { padding-inline: 0; }

@media (max-width: 1099px) {
  .cs-rail { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .cs-rail a, .cs-rail li::before { transition: none; }
}

/* ==========================================================================
   Night ground — OPT-IN PREVIEW, inert unless <html data-theme="night">
   The default site is unaffected.

   On #101012 the yellow reaches 17.7:1 and can finally carry text outright,
   so hierarchy comes from contrast rather than from a highlighter sweep
   behind ink. Inverting the ramp is the easy half; the rules below are the
   half that is easy to miss — every place where ink sat ON yellow has to be
   pinned to a dark constant, because an inverted --ink is light and light
   on yellow is 1.07:1.
   ========================================================================== */
:root[data-theme="night"], :root[data-theme="navy"] {
  --ink: #F2EFE6;          /* now the FOREGROUND — the ramp is inverted */
  --ink-soft: #D9D4C6;
  --paper: #101012;
  --paper-alt: #18181B;
  --grey-600: #A8A294;     /* 7.4:1 on the ground */
  --grey-400: #6F6A60;
  --rule-hairline: #3A3A40; /* lifted off #101012 rather than darkened */
  --border-light: #2A2A2E;
  --border-dark: #3A3A40;
  --on-accent: #121110;    /* the one colour allowed to sit on yellow */
}

/* Anything that paints ink ON the accent must not follow the inversion. */
:root[data-theme="night"], :root[data-theme="navy"] ::selection { background: var(--yellow); color: var(--on-accent); }
:root[data-theme="night"], :root[data-theme="navy"] .btn--fill { background: var(--yellow); color: var(--on-accent); border-color: var(--yellow); }
:root[data-theme="night"], :root[data-theme="navy"] .btn--fill:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
:root[data-theme="night"], :root[data-theme="navy"] .btn--accent { color: var(--on-accent); }
:root[data-theme="night"], :root[data-theme="navy"] .cs-pull mark { color: var(--on-accent); }
:root[data-theme="night"], :root[data-theme="navy"] .main-nav a:hover,
:root[data-theme="night"], :root[data-theme="navy"] .main-nav a:focus-visible,
:root[data-theme="night"], :root[data-theme="navy"] .cs-rail a:hover .cs-rail__label,
:root[data-theme="night"], :root[data-theme="navy"] .cs-rail a:focus-visible .cs-rail__label,
:root[data-theme="night"], :root[data-theme="navy"] .cs-next:hover .cs-next__title { color: var(--on-accent); }

/* The dark section was the seam between bands; on a dark ground it is the
   ground, so it flips to the light panel instead. */
:root[data-theme="night"], :root[data-theme="navy"] .section--dark { background: var(--paper-alt); color: var(--ink); }
:root[data-theme="night"], :root[data-theme="navy"] .section--dark .work-card { background: #0C0C0E; }

/* The hand and portrait are cut out of a light ground, so they keep theirs. */
:root[data-theme="night"], :root[data-theme="navy"] .hero__chip { background: var(--paper-alt); border-color: var(--border-light); }

/* The header glass was two hardcoded light rgba() values rather than tokens,
   so it stayed light over the dark ground. Same for the card hover wash. */
:root[data-theme="night"], :root[data-theme="navy"] .site-header { background: rgba(16, 16, 18, 0.62); }
:root[data-theme="night"], :root[data-theme="navy"] .site-header::after { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); }
@media (max-width: 980px) {
  :root[data-theme="night"], :root[data-theme="navy"] .site-header { background: rgba(16, 16, 18, 0.96); }
}
:root[data-theme="night"], :root[data-theme="navy"] .section:not(.section--dark) .work-card { background: var(--paper-alt); }

/* Ink navy variant of the same preview: identical structure, cooler ground.
   Blue is yellow's complement, so the accent separates by hue as well as by
   luminance — the strongest pairing available, at the cost of reading more
   "product company" than the neutral near-black. */
:root[data-theme="navy"] {
  --ink: #EDF0F7;
  --ink-soft: #CED5E4;
  --paper: #0F172A;
  --paper-alt: #16203A;
  --grey-600: #97A3BC;
  --grey-400: #5E6A85;
  --rule-hairline: #33415F;
  --border-light: #243150;
  --border-dark: #33415F;
  --on-accent: #0D1322;
}
:root[data-theme="navy"] .site-header { background: rgba(15, 23, 42, 0.62); }
@media (max-width: 980px) {
  :root[data-theme="navy"] .site-header { background: rgba(15, 23, 42, 0.96); }
}
:root[data-theme="navy"] .section--dark .work-card { background: #0B1222; }

/* ---------------------------------------------------------------------
   Contact sheets — the permutation boards
   A board is a canvas of every state the tracker can render. It is far too
   large to sit inline at a readable size, so it is split in two: the tile
   carries the shape of the set (a wall of states, legible as a shape even
   when the individual screens are not), the overlay carries the detail.
   The tile is a preview rather than a bare button because the preview is
   itself the evidence — the point lands before anyone taps.
--------------------------------------------------------------------- */
.cs-boards {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-4);
  /* More below than above: the heading that follows starts a new idea, and at
     the 32px the heading's own margin gave it the boards read as belonging to
     it. Margins collapse here, so this is the gap, not an addition to it. */
  margin-top: var(--space-5);
  margin-bottom: var(--space-6);
  align-items: start;
}
@media (max-width: 700px) {
  .cs-boards { grid-template-columns: 1fr; gap: var(--space-4); }
}

/* A lone sheet keeps a pair's tile width but centres in the column, rather than
   sitting in the left half of a grid built for two with a gap of empty page
   beside it. It spans both tracks and takes one track's width back by hand. */
/* Three sheets of the same family read as one set, so they share a row.
   Below the two-up breakpoint they stack like the rest. */
.cs-boards--trio { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 1024px) {
  .cs-boards--trio { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
  .cs-boards--trio { grid-template-columns: 1fr; }
}
.cs-boards > .cs-board:only-child {
  grid-column: 1 / -1;
  width: calc((100% - var(--space-4)) / 2);
  margin-inline: auto;
}
.cs-board { margin: 0; }

.cs-board__btn {
  display: block;
  position: relative;
  width: 100%;
  padding: 0;
  overflow: hidden;
  /* No border, and no white ground. The artwork's own canvas is #FEFDFF
     against a #FAFAFA page, and that 4-level step reads as an outline on its
     own. Multiply drops pure white out of the sheet so it blends into the
     page; the dark content and the status chips are unaffected at this
     lightness. */
  border: 0;
  border-radius: 10px;
  background: transparent;
  cursor: pointer;
  /* A fixed window on both boards so two canvases of very different height
     sit level. Anchored to the top because that is where the sheet starts. */
  aspect-ratio: 4 / 3;
}
.cs-board__btn img {
  display: block;
  mix-blend-mode: multiply;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
  transition: transform .4s cubic-bezier(.2,.7,.3,1);
}
.cs-board__btn:hover img,
.cs-board__btn:focus-visible img { transform: scale(1.02); }

/* The crop is deliberate, so it says so: the sheet fades out rather than
   being cut off, and the pill names the action. */
.cs-board__btn::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 26%;
  background: linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,.88) 72%, #FFF 100%);
  pointer-events: none;
}
/* A disc in the top-right corner rather than a label pill: same component
   language as .cs-phone__play on the order-tracker video above — ink ground,
   yellow glyph, the same inversion and 1.06 press — so the two read as one
   idea. In the corner it sits clear of the sheet it is sitting on, where a
   centred disc covered the rows the preview is meant to show. The button
   carries the name in aria-label, since the glyph is alone here. */
.cs-board__hint {
  position: absolute;
  z-index: 1;
  top: 12px;
  right: 12px;
  display: grid;
  place-items: center;
  /* A share of the tile so the disc holds its proportion at every width. It
     can sit under the 44px touch minimum because it is not the target — the
     whole tile is the button, and this is its mark. */
  width: clamp(32px, 11%, 46px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--ink);
  color: var(--yellow);
  box-shadow: 0 2px 6px rgba(80, 66, 40, 0.18),
              0 12px 28px -12px rgba(80, 66, 40, 0.35);
  transition: background var(--dur-fast) var(--ease-out),
              color var(--dur-fast),
              transform var(--dur-fast) var(--ease-out);
}
.cs-board__hint svg {
  /* The arrows fill their square where the eye was wide and short, so they
     take less of the disc. A little more of it at this size, where 48% left
     the strokes too fine to read. */
  width: 52%;
  height: auto;
}
.cs-board__btn:hover .cs-board__hint,
.cs-board__btn:focus-visible .cs-board__hint,
.cs-board__btn:active .cs-board__hint {
  background: var(--yellow);
  color: var(--ink);
  transform: scale(1.06);
}

.cs-board figcaption {
  padding-top: 12px;
  /* Same reasoning as .cs-figure's caption: it sits under its own tile. */
  text-align: center;
  color: var(--grey-600);
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
}

/* ---------------------------------------------------------------------
   Board overlay
   Full screen rather than a bottom sheet: the taller board runs 1:2.2, so
   height is the scarce dimension and a sheet would spend it on nothing.
   Two zoom states, not a continuous zoom: fit-to-width to read the set,
   then 1:1 to read a screen. Discrete states mean no gesture fighting the
   page scroll, and it is how anyone reads a contact sheet anyway.
--------------------------------------------------------------------- */
.cs-zoom {
  position: fixed;
  inset: 0;
  z-index: 2000;
  /* The page's own ground, so opening a sheet reads as the page continuing
     rather than a lightbox over it, and a sheet shorter than the viewport does
     not end in a hard band under it. */
  background: var(--bg);
  display: none;
}
.cs-zoom.is-open { display: block; }

.cs-zoom__scroll {
  position: absolute;
  inset: 0;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  /* A sheet shorter than the viewport sits in the middle of it rather than
     against the top edge with all the slack below. `safe` matters: once the
     sheet is taller than the viewport — which is the whole of 1:1 — centring
     would push its top out of reach above the scroll origin, and safe falls
     back to start there. justify-items keeps 1:1 honest too: the image's width
     is auto in that mode, and a stretched grid item would be snapped back to
     the container's width, undoing the zoom. */
  display: grid;
  justify-items: start;
  align-content: safe center;
}
.cs-zoom__img {
  display: block;
  width: 100%;
  height: auto;
  cursor: zoom-in;
  /* The exports that carry an alpha channel need a ground of their own, or
     their dark annotation text sits on whatever is behind the overlay. The
     overlay's own token, so a transparent sheet and the space around it are
     one surface with no seam at the artwork's edge. */
  background: var(--bg);
}
/* 1:1. max-width is released so the image overflows and the container pans. */
.cs-zoom.is-full .cs-zoom__img {
  width: auto;
  max-width: none;
  cursor: grab;
}
.cs-zoom.is-full .cs-zoom__scroll.is-dragging { cursor: grabbing; }

/* The only chrome the overlay keeps. It pins itself to the corner rather than
   riding a bar, and takes .btn--fill's treatment whole — ink ground, yellow
   mark, the 1.5px edge, inverting on hover with the same 2px lift every button
   on the site makes. Round rather than square because it is a close control,
   not a call to action; the pairing and the gesture are the button's. */
.cs-zoom__close {
  position: fixed;
  top: 16px;
  right: 20px;
  z-index: 1;
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  border: 1.5px solid var(--ink);
  border-radius: 50%;
  background: var(--ink);
  color: var(--yellow);
  cursor: pointer;
  /* The same pair the tile's expand disc floats on — a tight contact shadow
     under the edge and a wide, lifted one with the spread pulled back so the
     falloff stays long. It sits on the sheet's white here, the ground that
     pair was tuned for. */
  box-shadow: 0 2px 6px rgba(80, 66, 40, 0.18),
              0 12px 28px -12px rgba(80, 66, 40, 0.35);
  transition: background var(--dur-fast) var(--ease-out),
              color var(--dur-fast),
              box-shadow var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out);
}
.cs-zoom__close:hover,
.cs-zoom__close:focus-visible {
  background: var(--yellow);
  border-color: var(--yellow);
  color: var(--ink);
  transform: translateY(-2px);
  /* The shadow opens as it rises, so the 2px lift reads as distance rather
     than the disc simply shifting up. */
  box-shadow: 0 3px 8px rgba(80, 66, 40, 0.20),
              0 18px 36px -14px rgba(80, 66, 40, 0.42);
}
/* No ring on top of that. The button already states focus the way every button
   on the site does — it inverts to yellow and lifts — and the global outline
   sat as a second yellow mark around a disc that had just turned yellow. The
   inversion is the indicator; dropping the outline does not leave the control
   without one. */
.cs-zoom__close:focus-visible { outline: none; }
.cs-zoom__close svg { width: 16px; height: 16px; }

@media (max-width: 700px) {
  .cs-zoom__close { top: 12px; right: 14px; }
}

/* Both marks run a fifth smaller where a mouse is doing the pointing. They were
   sized for a finger, and at that size on a desktop they sat heavier on the
   artwork than a control of their importance should. Held back to the phone
   breakpoint rather than applied everywhere: on touch the close button is a
   real target and stays at the 44px minimum, and the expand disc stays large
   enough to hit on a tile. */
@media (min-width: 701px) {
  .cs-board__hint { width: clamp(26px, 8.8%, 37px); }
  .cs-zoom__close { width: 35px; height: 35px; }
  .cs-zoom__close svg { width: 13px; height: 13px; }
}
