/* ==========================================================================
   Cliff Hwang — Site layer
   Depends on tokens.css + base.css + components.css

   These are page-composition patterns the measured design system doesn't
   cover yet (it currently documents the home page only). Built with the
   same tokens and the same measurement discipline — pulled from the real
   /about, /tdo, /dls and /snap pages — but kept in a separate file so it
   never collides with edits to components.css.
   ========================================================================== */

/* --------------------------------------------------------------------------
   SCROLL REVEAL — Spotlight / Working with AI / Tools I built
   One-shot enter animation as each card scrolls into view. Jakub's recipe
   (opacity + translateY + blur "materializing" enter), Jhey's stagger via
   nth-child. Applied at the card/section level, not to individual lines or
   chips — animating every leaf node is the over-animating failure mode this
   skill warns against.

   The .ch-reveal class is added by JS (main.js) right before it's observed,
   never in static markup — if JS never runs, content is never hidden. The
   global prefers-reduced-motion rule in base.css already zeroes every
   transition-duration on the page, so reduced-motion support falls out of
   that for free: elements still end at their visible state, instantly.
   -------------------------------------------------------------------------- */
.ch-reveal {
  opacity: 0;
  transform: translateY(1rem);
  filter: blur(4px);
  /* A touch slower than --ch-duration-slow (500ms) so the reveal reads as a
     gentle settle rather than a snap. */
  transition: opacity   0.65s var(--ch-ease-out),
              transform 0.65s var(--ch-ease-out),
              filter    0.65s var(--ch-ease-out);
}
.ch-reveal--visible {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}

/* Stagger step comes from --ch-stagger (80ms); the third card is two steps in. */
.ch-ai-grid > .ch-reveal:nth-child(2)   { transition-delay: var(--ch-stagger); }
.ch-ai-grid > .ch-reveal:nth-child(3)   { transition-delay: calc(var(--ch-stagger) * 2); }
.ch-tool-list > .ch-reveal:nth-child(2) { transition-delay: var(--ch-stagger); }

/* CONTAINER — the shared .ch-container caps at --ch-content-max (1440) at
   every breakpoint and keeps its gutter throughout (32 / 24 / 16 desktop /
   tablet / mobile). */

/* --------------------------------------------------------------------------
   SKIP LINK
   -------------------------------------------------------------------------- */
.ch-skip {
  position: absolute;
  inset-inline-start: var(--ch-space-4);
  inset-block-start: -6.25rem;
  z-index: 20;
  background: var(--ch-amber-500);
  color: var(--ch-ink-900);
  padding: var(--ch-space-2) var(--ch-space-4);
  border-radius: var(--ch-radius-sm);
  font-weight: var(--ch-weight-semibold);
  transition: inset-block-start var(--ch-duration-fast) var(--ch-ease);
}
.ch-skip:focus { inset-block-start: var(--ch-space-4); }

/* --------------------------------------------------------------------------
   NAV — page shell + mobile toggle
   The measured nav (.ch-nav / .ch-nav__pill / .ch-nav__link) collapses to a
   hamburger below 768px; this behavior isn't on the live site's desktop-only
   capture, so it's a house convention, not a measurement.
   -------------------------------------------------------------------------- */
.ch-nav__inner { max-width: var(--ch-page-max); margin-inline: auto; }
.ch-nav__icon { width: 1.25rem; height: 1.25rem; }

/* Homepage-only override of the shared .ch-nav inset (components.css's
   24/16/16/24 was tuned for corner-radius alignment against a 40px pill;
   this page doesn't carry that constraint). Desktop: uniform 24px — a
   round, even inset now that the asymmetry no longer earns its keep.
   Mobile gets its own uniform reset below (16px, matching the compact
   width). */
.ch-nav { padding: var(--ch-space-6); }

/* Inner pill: 32px inline / 16px block. Same asymmetry as the wordmark and
   the section container gutters, just applied to the pill's own content
   inset instead of the outer wrapper. */
.ch-nav__pill { padding-block: var(--ch-space-4); padding-inline: var(--ch-space-8); }

.ch-nav__toggle { display: none; }

/* "Home" is redundant next to the wordmark (which already links home) in the
   tight desktop pill — dropped there per direction, kept in the roomier
   mobile dropdown where it reads more like a menu than a repeated link. */
.ch-nav__link--home { display: none; }

@media (max-width: 767.98px) {
  .ch-nav { padding: var(--ch-space-4); }
  .ch-nav__pill { padding-inline: var(--ch-space-4); padding-block: var(--ch-space-2); }
  .ch-nav__toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
  }
  .ch-nav__link--home { display: inline-flex; }
  .ch-nav__links {
    position: absolute;
    inset-inline: var(--ch-space-4);
    inset-block-start: calc(var(--ch-nav-height) + var(--ch-space-2));
    flex-direction: column;
    align-items: flex-start;
    gap: var(--ch-space-2);
    padding: var(--ch-space-4);
    border-radius: var(--ch-radius-xl);
    background: var(--ch-bg-nav);
    backdrop-filter: var(--ch-blur-nav);
    -webkit-backdrop-filter: var(--ch-blur-nav);
    box-shadow: var(--ch-glass-edge-light);
    display: none;
  }
  .ch-nav.is-open .ch-nav__links { display: flex; }
}

/* --------------------------------------------------------------------------
   HERO — home page
   The orb canvas is gone (per direction — it was a house invention, not
   measured, and added complexity the page didn't need). The hero is a
   single text column again: no second grid item, no backdrop layer.

   Sizing is now a direct spec rather than a measurement:
     - the section holds 3/4 of the viewport height, content vertically
       centered within it
     - the text column holds 2/3 of the container width
   -------------------------------------------------------------------------- */
.ch-home-hero {
  min-height: 75vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Top clearance is a floor, not just breathing room: the nav is
     position: fixed (its own top inset + pill height = ~96px), and on short
     mobile viewports the centered content can otherwise land right under
     it. padding-block-start must clear the nav; padding-block-end doesn't
     have that constraint. */
  padding-block-start: calc(var(--ch-space-6) + var(--ch-nav-height) + var(--ch-space-6));
  padding-block-end: var(--ch-space-16);
}

.ch-hero-grid {
  display: flex;
}

/* Small / large / small. The middle line now uses the shared display-1 role
   (.ch-hero) — see .ch-home-hero__statement below — so it draws its size,
   family and responsive scaling from --ch-hero-* rather than a one-off here.
   (The earlier note that this line was "Inter, not the display face" is moot
   since v0.4: display and body are both Noto Sans now.) Line heights across
   the hero are left to the font's natural leading (normal). */
.ch-hero-intro {
  display: flex;
  align-items: center;
  gap: var(--ch-space-2);
  font-family: var(--ch-font-body);
  font-weight: 400;
  font-size: 1.5rem;    /* 24px [was: 1.25rem/20px] */
  line-height:normal;
  color: var(--ch-accent);
}
.ch-wave-icon {
  width: 1.375rem;
  height: 1.375rem;
  flex: none;
  transform-origin: 70% 70%;
  animation: ch-wave 2.5s ease-in-out infinite;
}
@keyframes ch-wave {
  0%, 55%, 100% { transform: rotate(45deg); }
  10%           { transform: rotate(59deg); }
  20%           { transform: rotate(37deg); }
  30%           { transform: rotate(59deg); }
  40%           { transform: rotate(41deg); }
  50%           { transform: rotate(55deg); }
}
@media (prefers-reduced-motion: reduce) {
  .ch-wave-icon { animation: none; }
}
/* The statement carries the shared display-1 role (.ch-hero), which as of v0.5
   is sized to match the home hero (2.5rem / 2rem / 1.5rem, normal
   leading, no tracking) — so size, family, weight and leading come from
   --ch-hero-* and are not restated here. Only the emphasis is scoped: the
   sentence reads in secondary text with its opening phrase lifted to full white. */
.ch-home-hero__statement {
  color: var(--ch-text-secondary);
  /* .ch-hero sets text-wrap: balance, which deliberately narrows the line-
     wrapping measure so line lengths look even — it reads as the column
     itself being narrower than the true 66%-width box behind it. This
     statement should fill the full column instead. */
  text-wrap: wrap;
}
.ch-home-hero__statement strong {
  font-weight: var(--ch-weight-bold);
  color: var(--ch-text);
}
.ch-hero-meta {
  font-family: var(--ch-font-body);
  font-weight: 400;
  font-size: 1rem;
  line-height: normal;
  color: var(--ch-text-secondary);
}

/* Mobile: the intro and meta lines are scoped to this page, so they carry
   their own mobile size here. The statement is display-1 (.ch-hero) and gets
   its mobile step (1.5rem) from tokens.css, so it isn't restated. */
@media (max-width: 767.98px) {
  .ch-hero-intro   { font-size: 1.125rem; }
  .ch-hero-meta    { font-size: 0.875rem; }
}

/* HERO ON-APPEAR — the hero is always in the initial viewport, so its
   entrance is a pure-CSS load animation (no IntersectionObserver): each of
   the three lines fades up out of a soft blur, staggered top-to-bottom, so
   the eye lands on the statement last. Same "materialize" vocabulary as the
   scroll-reveal below (opacity + rise + de-blur, --ch-ease-out), just fired
   on load instead of on intersection. `both` fill holds the from-state during
   the delay, so there's no flash of the resting text before it animates.
   The keyframe END state is the natural resting state, so when the global
   prefers-reduced-motion rule (base.css) collapses the duration to ~0, every
   line simply snaps to visible — no motion, nothing hidden. */
@keyframes ch-hero-enter {
  from { opacity: 0; transform: translateY(1.25rem); filter: blur(6px); }
  to   { opacity: 1; transform: translateY(0);       filter: blur(0); }
}
.ch-home-hero .ch-hero-intro,
.ch-home-hero .ch-home-hero__statement,
.ch-home-hero .ch-hero-meta {
  animation: ch-hero-enter var(--ch-duration-slow) var(--ch-ease-out) both;
}
.ch-home-hero .ch-hero-intro        { animation-delay: 0.05s; }
.ch-home-hero .ch-home-hero__statement { animation-delay: 0.18s; }
.ch-home-hero .ch-hero-meta         { animation-delay: 0.34s; }

.ch-hero-grid__text {
  width: 66.6667%;
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-9);
}

@media (max-width: 1023.98px) {
  .ch-hero-grid__text { width: 100%; }
}

main {
  position: relative;
  overflow-x: hidden;
}

/* --------------------------------------------------------------------------
   HOME SECTION RHYTHM — desktop only
   Between the home content sections (Projects / Working with AI /
   Experiments) the gap opens to 8rem on desktop (>=1280px). The gap is the
   preceding section's padding-block-end, so it's applied to every content
   section except the last — the last section's spacing to the footer is the
   footer's own top padding, left untouched. The hero is deliberately excluded
   (it's .ch-hero-section, not .ch-section) and keeps its own bottom spacing.
   Below 1280px (tablet / mobile) sections keep the base 4rem from base.css. */
@media (min-width: 1280px) {
  .ch-home > .ch-section:not(:last-of-type),
  .ch-about > .ch-section:not(:last-of-type) {
    padding-block-end: 16rem;
  }
}
/* Tablet: 8rem between the home content sections (base 4rem stays on mobile). */
@media (min-width: 768px) and (max-width: 1279.98px) {
  .ch-home > .ch-section:not(:last-of-type) {
    padding-block-end: 8rem;
  }
}

/* --------------------------------------------------------------------------
   WORKING WITH AI — 3-column glass card grid (home page)
   -------------------------------------------------------------------------- */
.ch-ai-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--ch-space-8);
  counter-reset: ch-ai;
}
/* Numbered list items, not cards: no surface, no border. Each column is a
   plain stack — a leading-zero index (01/02/03), the subhead title, then the
   body copy. The index is a CSS counter on ::before, so the markup carries no
   hand-typed numbers and re-numbers itself if an item is added or reordered. */
.ch-ai-item {
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-2);
}
.ch-ai-item::before {
  counter-increment: ch-ai;
  content: counter(ch-ai, decimal-leading-zero);
  font-size: var(--ch-chip-size);
  color: var(--ch-text-muted);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.08em;
  margin-block-end: var(--ch-space-2);
}
@media (max-width: 1023.98px) {
  .ch-ai-grid { grid-template-columns: 1fr; }
}

/* --------------------------------------------------------------------------
   TOOL CARD — link wrapper + leading icon, restyled to shadcn's Card anatomy
   The shared .ch-tool (components.css) fills with --ch-bg-subtle and has no
   border — a filled-surface pattern, not shadcn's. shadcn's actual Card is
   `rounded-xl border bg-card ... shadow-sm`: a 1px border + a (mostly
   decorative, in dark mode) shadow, no fill. Scoped here rather than changed
   in components.css since that file is shared across every page.
   -------------------------------------------------------------------------- */
.ch-tool {
  flex-direction: row;
  align-items: center;
  gap: var(--ch-space-6);
  background: transparent;
  border: 1px solid var(--ch-border-strong);
  box-shadow: var(--ch-glass-edge-light);
  transition: border-color var(--ch-duration-fast) var(--ch-ease),
              box-shadow var(--ch-duration-fast) var(--ch-ease);
  /* now a <button> that opens a case-study modal: strip the UA button look
     and left-align, so it renders exactly like the card it replaced. */
  width: 100%;
  text-align: start;
  font: inherit;
  color: inherit;
  cursor: pointer;
  appearance: none;
}
.ch-tool:hover { border-color: var(--ch-border-accent); }
.ch-tool__icon {
  flex: none;
  width: 4rem;
  height: 4rem;
  border-radius: var(--ch-radius-md);
  object-fit: cover;
  background: var(--ch-ink-900);
}
.ch-tool__text { display: flex; flex-direction: column; gap: var(--ch-space-2); }
@media (max-width: 767.98px) {
  .ch-tool { flex-direction: column; align-items: flex-start; }
}

/* Two columns at tablet and desktop (the shared .ch-tool-list in
   components.css is a single-column stack); mobile keeps one column so each
   card's icon+text row (above) has room to stack vertically too. */
@media (min-width: 768px) {
  .ch-tool-list { display: grid; grid-template-columns: repeat(2, 1fr); }
}

/* --------------------------------------------------------------------------
   TOOL CASE-STUDY MODAL — native <dialog>
   Replicates the live site's tool modals (Data Swap / Lottie Player) using the
   platform dialog element — the vanilla stand-in for shadcn's Radix Dialog:
   it gives a real top-layer, ::backdrop scrim, focus trapping and Esc-to-close
   for free. Everything inside is built from the design-library tokens and the
   shadcn-derived primitives already in the system (ghost icon button, outline
   chips, the button component for the CTA).
   -------------------------------------------------------------------------- */
.ch-modal {
  /* Center in the viewport regardless of page scroll. A bare dialog computes
     to position: absolute (anchored to the document, so it drifts off-screen
     when the page is scrolled); fixed + inset:0 + margin:auto pins the sized
     box to the viewport center instead. */
  position: fixed;
  inset: 0;
  margin: auto;
  width: min(45rem, calc(100vw - 2rem));
  max-height: min(85vh, calc(100vh - 2rem));
  padding: 0;
  border: 1px solid var(--ch-border-strong);
  border-radius: var(--ch-radius-2xl);
  background: var(--ch-bg-subtle);
  color: var(--ch-text);
  overflow: hidden;
}
/* Only lay the dialog out when it's actually open. Without this, the base
   rule's display would override the UA's `dialog:not([open]){display:none}`,
   leaving the modal permanently on screen. Closed dialogs stay display:none. */
.ch-modal[open] {
  display: flex;
  flex-direction: column;
}
.ch-modal::backdrop {
  background: var(--ch-scrim-70);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}
.ch-modal[open] { animation: ch-modal-in var(--ch-duration-base) var(--ch-ease-out) both; }
.ch-modal[open]::backdrop { animation: ch-modal-fade var(--ch-duration-base) var(--ch-ease-out) both; }
/* When JS drives the card→modal morph, the CSS enter animation would fight the
   transform WAAPI applies — suppress it (the backdrop still fades). */
.ch-modal[open].is-morphing { animation: none; }
@keyframes ch-modal-in {
  from { opacity: 0; transform: translateY(0.75rem) scale(0.985); }
  to   { opacity: 1; transform: none; }
}
@keyframes ch-modal-fade { from { opacity: 0; } to { opacity: 1; } }

/* Mobile: the modal becomes a bottom sheet — full width, pinned to the bottom
   edge with no side margins, 90% of the viewport tall, rounded on top only,
   and it slides up from below instead of fading in place. */
@media (max-width: 767.98px) {
  .ch-modal {
    inset: auto 0 0 0;
    width: auto;
    max-width: none;
    height: 90vh;
    max-height: 90vh;
    margin: 0;
    border-radius: var(--ch-radius-2xl) var(--ch-radius-2xl) 0 0;
  }
  .ch-modal[open] { animation: ch-sheet-in var(--ch-duration-base) var(--ch-ease-out) both; }
  /* Tighter 16px padding all around on the bottom sheet. Extra specificity so
     it wins over the base .ch-modal__body rule that follows in source order. */
  .ch-modal .ch-modal__body { padding: var(--ch-space-4); }
}
@keyframes ch-sheet-in {
  from { transform: translateY(100%); }
  to   { transform: translateY(0); }
}

.ch-modal__close {
  position: absolute;
  inset-block-start: var(--ch-space-4);
  inset-inline-end: var(--ch-space-4);
  z-index: 2;
}
/* Larger close glyph than the button component's default 1rem icon. */
.ch-modal__close .ch-btn__icon { width: 1.5rem; height: 1.5rem; }
.ch-modal__body {
  overflow-y: auto;
  padding: var(--ch-space-8);
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-5);
}
/* The body scrolls, so its children must keep their natural height rather than
   being squished by flex-shrink (which flattened the CTA's control height and
   cropped the video). */
.ch-modal__body > * { flex-shrink: 0; }
.ch-modal__head {
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-2);
  padding-inline-end: var(--ch-space-10);   /* clear the close button */
}
.ch-modal__title {
  font-size: var(--ch-project-title-size);   /* display-3 */
  line-height: normal;
  font-weight: var(--ch-weight-bold);
  color: var(--ch-text);
}
.ch-modal__tools {
  display: flex;
  align-items: center;
  gap: var(--ch-space-3);
  flex-wrap: wrap;
}
.ch-modal__tools-label { font-size: var(--ch-chip-size); color: var(--ch-text-muted); }
.ch-modal__cta { align-self: flex-start; }
.ch-modal__media {
  overflow: hidden;
  border-radius: var(--ch-radius-lg);
  background: var(--ch-ink-900);
  border: 1px solid var(--ch-border);
  /* Keep the media at the video's natural height. As a flex item in the
     column body it would otherwise shrink vertically and crop the video,
     which is what squashed its aspect ratio. The body scrolls instead. */
  flex-shrink: 0;
}
.ch-modal__media video {
  display: block;
  width: 100%;
  height: auto;   /* preserves the video's original aspect ratio */
}
.ch-modal__section { display: flex; flex-direction: column; gap: var(--ch-space-3); }
.ch-modal__section-title {
  font-size: 1.125rem;
  line-height: normal;
  font-weight: var(--ch-weight-semibold);
  color: var(--ch-accent);
}
/* Real bullets: base.css resets ul to list-style:none, and a flex ul drops
   list markers entirely — so restore disc markers and use li margins (not
   flex gap) for spacing. */
.ch-modal__list {
  margin: 0;
  padding-inline-start: var(--ch-space-5);
  list-style: disc;
}
.ch-modal__list li {
  font-size: var(--ch-body-size);
  line-height: normal;
  color: var(--ch-text-secondary);
}
.ch-modal__list li + li { margin-block-start: var(--ch-space-2); }
.ch-modal__list li::marker { color: var(--ch-text-muted); }
.ch-modal__list li strong { color: var(--ch-text); font-weight: var(--ch-weight-semibold); }

/* --------------------------------------------------------------------------
   EXPERIMENTS — auto-scrolling ticker of real Lottie animations
   Confirmed from Framer's own static export: each tile is a looping
   <dotlottie-player> (250x250, radius 10, transparent) captioned "Animation",
   riding an infinitely-scrolling horizontal row (36px gap, overflow clipped).
   Not empty placeholders — five distinct named animations. The item list is
   duplicated in the HTML so the loop is seamless; the duplicate set is
   aria-hidden.
   -------------------------------------------------------------------------- */
.ch-ticker { overflow: hidden; }
.ch-ticker__track {
  display: flex;
  align-items: center;
  gap: 2.5rem;   /* desktop */
  width: max-content;
  list-style: none;
  margin: 0;
  padding: 0;
  animation: ch-ticker-scroll 32s linear infinite;
}
.ch-ticker__tile {
  flex: none;
  width: 15rem;   /* desktop */
  height: 15rem;
  border-radius: var(--ch-radius-md);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--ch-space-2-5);
}
.ch-ticker__tile dotlottie-player { width: 100%; height: 100%; }
@keyframes ch-ticker-scroll {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
  .ch-ticker__track { animation: none; overflow-x: auto; }
}
@media (min-width: 768px) and (max-width: 1279.98px) {   /* tablet */
  .ch-ticker__tile { width: 12rem; height: 12rem; }
  .ch-ticker__track { gap: 2rem; }
}
@media (max-width: 767.98px) {                            /* mobile */
  .ch-ticker__tile { width: 9rem; height: 9rem; }
  .ch-ticker__track { gap: 1.5rem; }
}

/* --------------------------------------------------------------------------
   CHIP — compact + outline modifiers
   Scoped here rather than added to the shared .ch-chip in components.css,
   since this is a homepage-specific treatment (smaller instead of full-size)
   rather than a system-wide chip change. .ch-chip--outline itself is no
   longer defined here — components.css now ships a canonical shadcn-derived
   version (box-shadow inset instead of border, color: var(--ch-text)); this
   file just uses that one instead of shadowing it with a near-duplicate. */
.ch-chip--compact {
  height: 1.75rem;
  padding-inline: var(--ch-space-3);
  gap: var(--ch-space-1);
  font-size: 0.75rem;
  line-height: normal;
}

/* --------------------------------------------------------------------------
   PROJECT CARD — 3-image gallery per case study
   Per direction: each case study gets three square placeholder images, not
   one. The three case studies stack vertically (one gallery + caption per
   project); within a single case study's gallery, desktop shows all three
   images side by side, and below that it becomes a horizontal, snap-
   scrolling strip with the next/previous image of that SAME set peeking in.
   Caption below the gallery: title is the smaller element now, not the
   dominant one.
   -------------------------------------------------------------------------- */
.ch-card-body--bright { color: var(--ch-text); }

.ch-project-grid {
  display: flex;
  flex-direction: column;
  gap: 12rem;
}
@media (max-width: 1279.98px) { .ch-project-grid { gap: 6rem; } }  /* tablet */
@media (max-width: 767.98px)  { .ch-project-grid { gap: 4rem; } }  /* mobile */

.ch-project {
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-4);
}

.ch-project__gallery {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--ch-space-4);
}

.ch-project__image {
  aspect-ratio: 1 / 1;
  border-radius: var(--ch-radius-xl);
  background: var(--ch-bg-subtle);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ch-text-muted);
}
.ch-project__image svg { width: 20%; height: 20%; }

/* SNAP case study, first gallery tile: the phone + Lone Star EBT card composed
   on a dark ground. The card sits behind the phone; both are transparent PNGs.
   Positioned by their centre (left/top + translate -50%) so the rotations pivot
   in place. Angles per direction: phone -10deg, card +25deg. */
.ch-snap-hero {
  position: relative;
  overflow: hidden;
  background: #DA291C;
  border: 1px solid var(--ch-border);
}
.ch-snap-hero__phone,
.ch-snap-hero__card {
  position: absolute;
  display: block;
}
.ch-snap-hero__card {
  width: 48%;
  left: 66%; top: 34%;
  transform: translate(-50%, -50%) rotate(25deg);
  z-index: 1;
  /* second drop-shadow is the hover glow, invisible at rest; kept in the list
     so the filter interpolates smoothly on hover. */
  filter: drop-shadow(0 0.5rem 1.25rem rgba(0, 0, 0, 0.45))
          drop-shadow(0 0 0 rgba(255, 255, 255, 0));
  transition: filter 0.5s ease;
}
.ch-snap-hero:hover .ch-snap-hero__card {
  filter: drop-shadow(0 0.5rem 1.25rem rgba(0, 0, 0, 0.45))
          drop-shadow(0 0 0.9rem rgba(255, 255, 255, 0.5));
}
@media (prefers-reduced-motion: reduce) {
  .ch-snap-hero__card { transition: none; }
}

/* tdo case study, first gallery tile: the "Savings Applied" confirmation
   Lottie — same asset as the /tdo page. Fills the tile; the player preserves
   the animation's aspect ratio. */
/* SNAP program logo tile: centered on a white ground. */
.ch-snap-logo { overflow: hidden; background: #ffffff; }
.ch-snap-logo img { width: 55%; height: auto; max-height: 70%; object-fit: contain; }

/* Shared cover treatment for gallery tiles holding a single image. */
.ch-cover { overflow: hidden; }
.ch-cover img,
.ch-cover video { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Backdrop for a tile whose image has a transparent background. Uses the DLS
   widget's light-mode button blue (#2563eb) to tie the two tiles together. */
.ch-cover--tint { background: #2563eb; }

/* tdo case study, second gallery tile: the intro video, filling the tile. */
.ch-tdo-video { overflow: hidden; }
.ch-tdo-video video { width: 100%; height: 100%; object-fit: cover; display: block; }

/* tdo case study, third gallery tile: the scan image, filling the tile. */
.ch-tdo-scan { overflow: hidden; }
.ch-tdo-scan img { width: 100%; height: 100%; object-fit: cover; display: block; }

.ch-tdo-lottie { overflow: hidden; position: relative; }
.ch-tdo-lottie dotlottie-player { width: 100%; height: 100%; }
/* cursor overlaying the CTA button inside the Lottie, at its bottom-right with
   a slight overlap. Tuned against the animation's button position. */
.ch-tdo-cursor {
  position: absolute;
  left: 63%; top: 52%;
  width: 7.5%; height: auto;
  z-index: 2;
  pointer-events: none;
  filter: drop-shadow(0 0.25rem 0.5rem rgba(0, 0, 0, 0.35));
}
.ch-snap-hero__phone {
  height: 70%;
  left: 38%; top: 55%;
  transform: translate(-50%, -50%) rotate(-10deg);
  z-index: 2;
  filter: drop-shadow(0 0.75rem 1.75rem rgba(0, 0, 0, 0.55));
}

.ch-project__text { display: flex; flex-direction: column; gap: var(--ch-space-4); }

/* Title uses the shared .ch-subhead role (24px/600, scales down on its own
   at tablet/mobile) rather than a one-off size — per direction. */

@media (max-width: 1023.98px) {
  .ch-project__gallery {
    display: flex;
    flex-direction: row;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    scroll-padding-inline: var(--ch-gutter);
    gap: var(--ch-space-4);
    /* Scrollbar is redundant with the snap + peek affordance. */
    scrollbar-width: none;
  }
  .ch-project__gallery::-webkit-scrollbar { display: none; }
  .ch-project__gallery > .ch-project__image {
    flex: 0 0 82%;
    scroll-snap-align: center;
  }
}

/* Tablet: two images in full, ~10% of the third peeking past the edge to
   signal there's more to scroll. */
@media (min-width: 768px) and (max-width: 1023.98px) {
  .ch-project__gallery > .ch-project__image {
    flex-basis: calc(46.5% - var(--ch-space-2));
    scroll-snap-align: start;
  }
}

/* Mobile: one image in focus (centre-snapped) with ~10% of the neighbouring
   image peeking on each side. */
@media (max-width: 767.98px) {
  .ch-project__gallery > .ch-project__image { flex-basis: 76%; }
}

/* --------------------------------------------------------------------------
   FOOTER — social row
   -------------------------------------------------------------------------- */
/* Re-measured 2026-08-13 on the live home page and /tdo. The CTA is the full
   --ch-project-title role (40px), centred across the column — not a small inline
   link. Rhythm below it: 60px to the social row, 40px on to the copyright. */
.ch-footer__cta {
  display: block;
  margin-block-end: var(--ch-space-16);   /* [was: 60] */
}
.ch-footer__social {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--ch-space-8);                 /* [was: 32] measured exact */
  margin-block-end: var(--ch-space-10);   /* [was: 40] measured exact */
}
.ch-footer__social a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--ch-space-20);              /* 80px  [was: 79] */
  height: var(--ch-space-20);
  padding: var(--ch-space-6);
  border-radius: var(--ch-radius-full);
  background: var(--ch-bg-scrim);         /* [was: rgba(2,2,2,.6)] */
}
.ch-footer__social svg { width: var(--ch-space-8); height: var(--ch-space-8); }  /* 32px [was: 18] */

/* --------------------------------------------------------------------------
   FOOTER — home page row variant
   A single left-to-right bar instead of the centred stack: contact links on
   the left (LinkedIn, then a copy-email button), copyright pushed to the far
   right. The email is a <button>, not a mailto — clicking it copies the
   address and flashes a toast rather than opening a mail client.

   2026-09-08: this is now the site's only footer. /tdo was moved onto it; the
   centred .ch-footer__cta / __social block above is what /about, /dls and
   /snap still carry and should be retired as each is migrated. The earlier
   note here said case studies keep the centred layout — that was live-site
   fidelity, which the library now outranks.
   -------------------------------------------------------------------------- */
/* Height hugs its content: a tight top/bottom padding instead of the base
   footer's tall 160/64 stack. Horizontal insets stay on .ch-container. */
.ch-footer--row {
  text-align: start;
  padding: var(--ch-space-6) 0;
}
.ch-footer--row .ch-footer__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ch-space-6);
  flex-wrap: wrap;
}
.ch-footer__contact {
  display: flex;
  align-items: center;
  gap: var(--ch-space-8);
  flex-wrap: wrap;
}
.ch-footer__link {
  position: relative;                 /* anchors the copied-toast */
  display: inline-flex;
  align-items: center;
  gap: var(--ch-space-2);
  font: inherit;
  font-size: var(--ch-body-size);
  color: var(--ch-text-secondary);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  transition: color var(--ch-duration-fast) var(--ch-ease);
}
.ch-footer__link:hover { color: var(--ch-text); }
.ch-footer__link svg { width: 1.25rem; height: 1.25rem; flex: none; }
.ch-footer__toast {
  position: absolute;
  /* Sits above the email button (not to its right, which overflowed the
     viewport on narrow screens and forced a horizontal scroll). */
  inset-block-end: calc(100% + var(--ch-space-2));
  inset-inline-start: 0;
  white-space: nowrap;
  padding: var(--ch-space-2) var(--ch-space-3);
  border-radius: var(--ch-radius-sm);
  background: var(--ch-neutral-800);
  border: 1px solid var(--ch-white-a16);
  color: var(--ch-text);
  font-size: var(--ch-chip-size);
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transform: translateY(0.25rem);
  transition: opacity var(--ch-duration-fast) var(--ch-ease),
              transform var(--ch-duration-fast) var(--ch-ease),
              visibility var(--ch-duration-fast) var(--ch-ease);
}
.ch-footer__toast.is-visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}
@media (max-width: 767.98px) {
  .ch-footer--row .ch-footer__inner {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--ch-space-6);
  }
  .ch-footer__contact { gap: var(--ch-space-6); }
}

/* --------------------------------------------------------------------------
   ABOUT — hero: the title with the lead copy stacked directly beneath it on the
   left, and the portrait beside them on the right.
   -------------------------------------------------------------------------- */
.ch-about-hero {
  display: flex;
  align-items: center;
  gap: var(--ch-space-8);              /* 2rem between the text and the portrait */
}
.ch-about-hero__text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: var(--ch-space-10); }
.ch-about-hero__headline { display: flex; flex-direction: column; }
.ch-about-hero__copy { display: flex; flex-direction: column; gap: var(--ch-space-6); }
.ch-about-hero__portrait {
  position: relative;
  flex: 0 0 auto;
  width: 30rem;
  max-width: 100%;
  aspect-ratio: 3 / 4;
  border-radius: var(--ch-radius-2xl);
  overflow: hidden;
  background: var(--ch-ink-850);
}
/* Zoomed and panned onto the face: width > 100% is the zoom, left/translateX
   centres horizontally, top pans the crop down to the face. */
.ch-about-hero__portrait img {
  position: absolute;
  top: -42%;
  left: 50%;
  width: 145%;
  max-width: none;
  height: auto;
  transform: translateX(-50%);
}

@media (max-width: 1023.98px) {
  .ch-about-hero { flex-direction: column; align-items: flex-start; gap: var(--ch-space-8); }
  .ch-about-hero__portrait { width: clamp(12rem, 52vw, 18rem); }
}
@media (max-width: 767.98px) {
  .ch-about-hero { gap: var(--ch-space-12); }   /* mobile keeps a larger stacked gap */
}

/* Toolkit — an auto-scrolling carousel of tool logos (the track is doubled in
   the markup so the -50% scroll loops seamlessly). No tile background: the
   logos sit directly on the page. Reuses the ticker's keyframe. */
.ch-toolkit {
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.ch-toolkit__track {
  display: flex;
  align-items: center;
  gap: var(--ch-space-32);            /* 8rem between each logo */
  width: max-content;
  margin: 0;
  padding: 0;
  list-style: none;
  animation: ch-ticker-scroll 40s linear infinite;
}
.ch-toolkit__item { flex: none; }
.ch-toolkit__item img {
  width: 4.5rem;
  height: 4.5rem;
  object-fit: contain;
  display: block;
}
@media (max-width: 1023.98px) { .ch-toolkit__track { gap: var(--ch-space-24); } }  /* 6rem, tablet */
@media (max-width: 767.98px)  { .ch-toolkit__track { gap: var(--ch-space-16); } }  /* 4rem, mobile */
@media (prefers-reduced-motion: reduce) {
  .ch-toolkit__track { animation: none; overflow-x: auto; }
}

/* --------------------------------------------------------------------------
   ABOUT — Outside of work: a single paragraph set to two-thirds of the column.
   -------------------------------------------------------------------------- */
.ch-interests { max-width: 66.6667%; }
@media (max-width: 1023.98px) {
  .ch-interests { max-width: none; }
}

/* --------------------------------------------------------------------------
   NOT FOUND (404)
   A single-screen error page built entirely on the home hero: it reuses
   .ch-home-hero (the min-66vh centred well + the staggered fade-up load
   animation) and .ch-hero-grid / __text (the two-thirds column), so size,
   rhythm and motion all come from the system rather than being re-specified.
   The only page-specific pieces are the oversized "404" numeral and the
   button row, both defined here.
   -------------------------------------------------------------------------- */

/* Oversized numeral. Draws its family/weight from the display role and sits a
   step above the hero size, with amber-on-dark for the one accent on the page.
   clamp() lets it grow on wide viewports without a bespoke breakpoint. */
.ch-notfound__code {
  font-family: var(--ch-font-display);
  font-weight: var(--ch-weight-bold);
  font-size: clamp(4rem, 18vw, 9rem);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--ch-accent);
}

/* The action row is the fourth line of the hero, so it fades up on the same
   ch-hero-enter keyframe (defined above) one step after the meta line. */
.ch-notfound__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ch-space-4);
}
.ch-home-hero .ch-notfound__actions {
  animation: ch-hero-enter var(--ch-duration-slow) var(--ch-ease-out) both;
  animation-delay: 0.5s;
}

/* ==========================================================================
   CASE STUDY PAGES (/tdo, /dls, /snap)

   Measured from the live pages at 1440 / 810 / 390 on 2026-08-13. All three
   share one Framer template; only the eyebrow accent differs per study, so
   it is exposed as --ch-case-accent and set by a modifier on the wrapper.

   The template is a single 926px reading column. It is NOT the home page's
   glass-card system — the summary block is a plain two-column row, and the
   only surface on the page is the grey well behind the screenshots.
   ========================================================================== */

/* Modifiers are named for the STUDY, not the colour. The Sept 2026 re-publish
   rotated two of the three accents (/tdo amber -> green, /dls green -> blue),
   which is exactly what a colour-named modifier cannot survive. The old
   colour names are kept as aliases so nothing breaks mid-migration. */
.ch-case               { --ch-case-accent: var(--ch-case-accent-tdo); }    /* /tdo  */
.ch-case--dls          { --ch-case-accent: var(--ch-case-accent-dls); }    /* /dls  */
.ch-case--snap         { --ch-case-accent: var(--ch-case-accent-snap); }   /* /snap */

/* deprecated aliases */
.ch-case--green        { --ch-case-accent: var(--ch-case-accent-dls); }
.ch-case--red          { --ch-case-accent: var(--ch-case-accent-snap); }

/* Container. Two widths: the hero + cover run edge-to-edge inside the
   column, the prose is inset by --ch-case-pad-text. */
.ch-case-wrap {
  margin-inline: auto;
  padding-inline: var(--ch-case-pad);
}
.ch-case-wrap--text { padding-inline: var(--ch-case-pad-text); }

/* 926px is a fixed DESKTOP width, not a fluid cap. Below the desktop
   breakpoint the column goes full-bleed and the gutters take over. The
   --ch-case-max cap applies only at >=1024 (the desktop breakpoint). */
@media (min-width: 1024px) {
  .ch-case-wrap { max-width: var(--ch-case-max); }
}

/* --- Intro section: title + meta, cover, summary ------------------------ */
.ch-case-intro {
  display: flex;
  flex-direction: column;
  gap: var(--ch-case-intro-gap);
  padding-block: var(--ch-case-intro-top) var(--ch-case-intro-bottom);
}

/* 60 / 40 of the 926 column. CORRECTED 2026-08-13: the gutter is 32px, not
   16 — read off the row's own computed `gap`. With 32 the tracks resolve to
   536 / 357, which is what the live title and meta columns actually measure
   at 1440. The 546fr/364fr ratio itself was right and is kept. */
.ch-case-hero {
  display: grid;
  grid-template-columns: 546fr 364fr;
  gap: var(--ch-space-8);              /* [was: 16] */
  align-items: start;
}
.ch-case-hero__title {
  font-family: var(--ch-font-body);
  font-weight: var(--ch-weight-bold);
  font-size: var(--ch-project-title-size);
  line-height:normal;
  color: var(--ch-text);
}
@media (max-width: 1023.98px) {
  .ch-case-hero { grid-template-columns: 1fr; }
}

/* Timeline / Platform / Role.
   CORRECTED 2026-08-13: 16px between items, not 32. Measured at both 1440 and
   390 — label 167→value 198→next label 235 desktop, and 208→238→275 mobile,
   which is an 8px inner gap and a 16px item gap on both. */
.ch-case-meta { display: flex; flex-direction: column; gap: var(--ch-space-4); }  /* [was: 32] */
.ch-case-meta__item { display: flex; flex-direction: column; gap: var(--ch-space-2); }
.ch-case-meta__label {
  font-size: var(--ch-case-meta-size);
  line-height:normal;
  font-weight: var(--ch-weight-medium);
  text-transform: uppercase;
  color: var(--ch-text-secondary);
}
.ch-case-meta__value {
  font-size: var(--ch-case-metaval-size);
  line-height:normal;
  color: var(--ch-text-secondary);
}

/* CORRECTED 2026-08-13: 32px at desktop, not 24 — the cover and the glass
   cards share --ch-radius-2xl. The 8px mobile override below is right and is
   kept (re-measured at 390). */
.ch-case-cover {
  border-radius: var(--ch-radius-2xl);   /* [was: 24] */
  overflow: hidden;
  background: var(--ch-bg-subtle);
}
.ch-case-cover img { width: 100%; height: auto; }
@media (max-width: 767.98px) {
  .ch-case-cover { border-radius: var(--ch-radius-sm); }
}

/* Summary — label left, copy right, 50/50. No card, no fill. */
.ch-case-summary { display: flex; flex-direction: column; gap: var(--ch-space-6); }
.ch-case-summary__row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: start;      /* live label box is its own 30px, not row height */
  padding: var(--ch-space-4);
}
.ch-case-summary__body {
  font-size: var(--ch-body-size);
  line-height:normal;
  color: var(--ch-text-secondary);
}

/* Figures inside the summary. Note this does NOT reuse .ch-emph: that role
   lifts a metric to full white at the SAME weight, which was the emphasis
   signal when the ramp had more weights to spend. Here the body is already
   secondary grey, so weight carries it — 700 against 400 reads at a glance
   where #fff against #e0e0e0 barely would. */
.ch-case-summary__body strong {
  font-weight: var(--ch-weight-bold);
  color: var(--ch-text);
}
@media (max-width: 767.98px) {
  .ch-case-summary__row { grid-template-columns: 1fr; }
}

/* --- Body section: the three long-form blocks --------------------------- */
.ch-case-body {
  display: flex;
  flex-direction: column;
  gap: var(--ch-case-body-gap);
  padding-block: var(--ch-case-body-top) var(--ch-case-body-bottom);
}
.ch-case-block { display: flex; flex-direction: column; gap: var(--ch-space-6); }

/* Subsection rhythm. The block's 24px gap is right between a subhead and
   its own content, but it gives a NEW subhead no more room than a paragraph
   gets, so subsections don't read as groups. A subhead that follows content
   opens up to 4rem (64px) total. The first subhead in a block — the one
   directly under the section eyebrow — stays at 24px, hugging its label.
   Written as 4rem minus the flex gap so the two stay in step if either token
   moves. */
.ch-case-block > :not(.ch-case-eyebrow) + .ch-subhead {
  margin-block-start: calc(var(--ch-space-16) - var(--ch-space-6));
}

/* The one accented element on the page. Shared by the summary labels and
   the section eyebrows — same size, same weight, same color. */
.ch-case-eyebrow {
  font-size: var(--ch-case-sub-size);
  line-height:normal;
  font-weight: var(--ch-weight-bold);
  color: var(--ch-case-accent);
}
.ch-case-block__title {
  font-family: var(--ch-font-body);
  font-weight: var(--ch-weight-bold);
  font-size: var(--ch-case-block-size);
  line-height:normal;
  color: var(--ch-text);
}

/* Prose column. Framer renders this as one rich-text field: no flex gap,
   every block after the first carries a 20px margin-block-start. The
   in-prose headings are visually plain paragraphs (400 weight) — that is
   measured, not an oversight. */
.ch-case-prose {
  font-size: var(--ch-body-size);
  line-height:normal;
  color: var(--ch-text);
}
.ch-case-prose > * + * { margin-block-start: var(--ch-case-prose-gap); }
/* The --tight modifier was removed: it reproduced a Framer authoring accident
   (the live "Results & Impact" block sets paragraph spacing to 0 while every
   other block uses 20px). The library has one prose rhythm, so every block
   now takes --ch-case-prose-gap. */
.ch-case-prose__head {
  font-size: var(--ch-case-sub-size);
  line-height:normal;
  font-weight: var(--ch-weight-regular);
  color: var(--ch-text);
}

/* Screenshot well — the only filled surface in the template.
   Measured #363636; mapped to --ch-bg-strong (#3b3b3b) per the v0.2
   neutral-ramp consolidation. */
.ch-case-figure {
  display: flex;
  /* Without this the row stretches the shorter screenshot to the taller one's
     height, distorting its aspect ratio (visible at mobile, where the images
     are width-driven). Live centres them. */
  align-items: center;
  justify-content: center;
  gap: var(--ch-space-3);              /* [was: 10] */
  padding: var(--ch-space-8);
  border-radius: var(--ch-radius-xl);
  background: var(--ch-bg-strong);
}
.ch-case-figure--pair { gap: var(--ch-space-16); }
.ch-case-figure img { width: auto; height: auto; max-width: 100%; }

/* /tdo's step-flow diagram sits flush in its well — no padding, 16px radius,
   and the image renders at a fixed 320px instead of filling the column. */
.ch-case-figure--flush {
  padding: 0;
  border-radius: var(--ch-radius-lg);
}
.ch-case-figure--flush img { width: 20rem; }        /* [was: 319] */

/* The step-3 "Confirmation (Savings Applied)" well is not a flat screenshot:
   the live page overlays a Lottie on the phone UI, absolutely positioned
   inside the well (measured inset 212.19px 296.02px 47px 300.50px on a
   866x307 well, i.e. 269x47 sitting over the 319x307 screenshot).

   Expressed as percentages of the screenshot rather than pixels so the
   overlay tracks the image if it ever scales below its 320px width.
     left  27/319   top 212/307   width 269/319   height 47/307          */
.ch-case-figure__media { position: relative; flex: none; line-height: 0; }

.ch-case-lottie {
  position: absolute;
  inset-inline-start: 8.464%;
  inset-block-start: 69.055%;
  width: 84.326%;
  height: 15.309%;
  overflow: hidden;          /* the live SVG uses preserveAspectRatio slice */
  pointer-events: none;
}

/* The source animation is 600x80 (7.5:1) rendered into a 5.72:1 box with
   "slice", i.e. scaled to cover and cropped left/right. dotlottie-player has
   no slice attribute, so the crop is reproduced here: the player is sized to
   the cover width (352.5/269 = 131.04%) and centred, and the wrapper clips. */
.ch-case-lottie dotlottie-player {
  /* The player ships its own font-family (Karla). It renders no text, so this
     is inert — but the library is a one-typeface system, so don't let a
     third-party family onto the page at all. */
  font: inherit;
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 50%;
  transform: translateX(-50%);
  width: 131.04%;
  height: 100%;
}

/* Full-bleed screenshot with no well behind it — /tdo's "Initial Concept"
   image is a bare image at the full width of the prose column. */
.ch-case-image { width: 100%; height: auto; }

/* A captioned bare image. The figure keeps its own tight gap so the caption
   hugs the image instead of taking the block's 24px, and the caption sits one
   step down the ramp in italic so it reads as a label, not a paragraph. */
.ch-case-illustration {
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-2);
  margin: 0;
}
.ch-case-caption {
  font-style: italic;
  font-size: var(--ch-chip-size);
  line-height: normal;
  color: var(--ch-text-muted);
}

/* Same bare image, but with a corner radius — /dls's "Token Architecture"
   diagram is the one screenshot on either page that sits on the page ground
   with no well and still carries a 16px radius. */
.ch-case-image--rounded { border-radius: var(--ch-radius-lg); }

/* Same idea one step up: /snap's strategy screenshot also sits on the page
   ground with no well, but its radius measures 24px, not 16 — checked on the
   live page at 1440 and 1024 on 2026-09-09. At 390 it drops to 16px, the same
   way the cover drops from 32 to 8. Both values are measured, and both land on
   an existing radius token, so this is a modifier rather than a one-off. */
.ch-case-image--rounded-xl { border-radius: var(--ch-radius-xl); }
@media (max-width: 767.98px) {
  .ch-case-image--rounded-xl { border-radius: var(--ch-radius-lg); }
}

/* .ch-case-prose__note was removed. It carried the live page's 500 weight on
   the "The launch was covered by …" line, but v0.6 collapsed the ramp to two
   weights — --ch-weight-medium now resolves to 600, which would set that line
   at heading weight. The library has no "slightly emphasised" tier, so the
   line is plain body copy. Use .ch-emph if a word genuinely needs lifting. */

/* Numbered step list (tdo's 3-step flow)
   CORRECTED 2026-08-13 against the live page. The numeral IS a badge: a 24px
   circle filled #292929 (mapped to --ch-bg-muted / neutral-700 per the v0.2
   ramp), with an amber numeral. The step title sits inline beside the badge;
   the description drops to its own line and is NOT indented under it — it
   starts flush with the prose column. Every gap here measures 24px. */
.ch-steps { display: flex; flex-direction: column; gap: var(--ch-space-6); }
.ch-step { display: flex; flex-direction: column; gap: var(--ch-space-6); }
.ch-step__head { display: flex; align-items: center; gap: var(--ch-space-3); }  /* [was: 10] */
.ch-step__index {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--ch-space-6);
  height: var(--ch-space-6);
  border-radius: var(--ch-radius-full);
  background: var(--ch-bg-muted);
  font-size: var(--ch-body-size);
  font-weight: var(--ch-weight-bold);
  line-height: normal;
  color: var(--ch-case-accent, var(--ch-text-accent));
}
.ch-step__title {
  font-size: var(--ch-body-size);
  font-weight: var(--ch-weight-bold);
  line-height: normal;
  color: var(--ch-text);
}
.ch-step__body {
  font-size: var(--ch-body-size);
  line-height:normal;
  color: var(--ch-text);
}

/* Pull quotes moved to components.css in this pass — they are a shared
   component now (.ch-quote / __text / __cite), with an accent bar that reads
   --ch-case-accent so each study tints its own. Nothing case-specific left. */

/* Results stat row.
   CORRECTED 2026-08-13: label is secondary grey, not white; the card carries
   16px padding and an 8px radius but no fill; number-to-label gap is 12px. */
.ch-stat-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--ch-space-6);
}
/* ADDITION, not a measurement: the live cards carry padding and a radius but
   no border, so each metric floats without an edge. The border gives the three
   figures a card each and makes the row read as a set.

   It takes the study accent rather than the neutral hairline, so the metric
   row and the quote bar are the one accented pair in the body — and each case
   study tints its own. */
.ch-stat {
  display: flex;
  flex-direction: column;
  gap: var(--ch-space-3);              /* [was: 10] */
  padding: var(--ch-space-4);
  border: 1px solid var(--ch-case-accent, var(--ch-border));
  border-radius: var(--ch-radius-sm);
}
.ch-stat__number {
  font-family: var(--ch-font-display);
  font-weight: var(--ch-weight-bold);
  font-size: var(--ch-section-size);
  line-height:normal;
  color: var(--ch-text);
}
.ch-stat__label {
  font-size: var(--ch-body-size);
  line-height:normal;
  color: var(--ch-text-secondary);
}
@media (max-width: 767.98px) { .ch-stat-row { grid-template-columns: 1fr; } }

/* In-prose link. CORRECTED 2026-08-13: the live link is white and underlined,
   the same color as the copy around it — it is not accented. base.css still
   lifts it to amber on hover. */
.ch-case-link {
  color: inherit;
  text-decoration: underline;
}

/* --------------------------------------------------------------------------
   CASE STUDY — figure sizing (added for /dls)

   .ch-case-figure gave the well its fill, padding and gap but never sized the
   images inside it, so intrinsic width won and the screenshots blew out the
   column. Both rules below are measured off /dls at 1440 / 1024 / 390.
   -------------------------------------------------------------------------- */

/* Paired portrait screenshots. Height-driven at desktop and tablet — the two
   images scale to a shared HEIGHT, not a shared width (their aspect ratios
   differ slightly). RE-MEASURED 2026-09-08: the Sept re-publish dropped them
   from 300px to 206px tall (125x206 and 122x206). 13rem [was: 206] puts the
   height back on the 4px grid. At mobile the well runs out of room first and
   they become width-driven inside the 64px gap. */
.ch-case-figure--pair img { height: 13rem; width: auto; }   /* 208px [was: 206] */
@media (max-width: 767.98px) {
  .ch-case-figure--pair img {
    height: auto;
    width: calc(50% - var(--ch-space-8));
  }
}

/* Single landscape screenshot, centred in its well. Measured 569px wide at
   both 1440 and 1024 — a fixed cap, not a percentage — and 100% of the inner
   box once the column narrows past it. 36rem [was: 569px] puts the cap back
   on the 4px grid. */
.ch-case-figure--single img { width: 100%; max-width: 36rem; height: auto; }

/* Stat label with trailing platform icons (/dls "Platforms"). The icons sit
   inline after the label at the measured 24px, inheriting the label color. */
.ch-stat__label--icons {
  display: flex;
  align-items: center;
  gap: var(--ch-space-3);              /* [was: 10] */
}
.ch-stat__label--icons svg {
  width: 1.5rem;
  height: 1.5rem;
  flex: none;
}
