/* ==========================================================================
   Case study template
   Loaded after site.css, which supplies the tokens, header and footer.

   Three content measures, all centred on the page:
     --w-prose  640px   body copy, section headings, details
     --w-mid    960px   before/after comparison, option cards
     --w-wide  1080px   figures, more-projects grid
   ========================================================================== */

:root {
  --w-prose: 640px;
  --w-mid: 960px;
  --w-wide: 1080px;

  --fs-case-title: clamp(34px, 3.265vw, 48px);  /* 48px @1470 */
  --fs-statement: clamp(24px, 2.45vw, 36px);    /* 36px @1470 */
  --fs-section: clamp(19px, 1.5vw, 22px);       /* 22px @1470 */
  --fs-prose: clamp(17px, 1.29vw, 19px);        /* 19px @1470 */

  --pill-accent: #1d6b63;
  --panel: #f5f5f5;
  --panel-2: #ffffff;
}

/* --- Measures ------------------------------------------------------------ */

.m-prose,
.m-mid,
.m-wide {
  width: 100%;
  margin-inline: auto;
}
.m-prose { max-width: var(--w-prose); }
.m-mid   { max-width: var(--w-mid); }
.m-wide  { max-width: var(--w-wide); }

/* The rail's own links, cleared of the pinned header. Same rule and same
   reasoning as the phone block states at the bottom of this file — the header
   plus a cell of air — one breakpoint up, off the desktop header's 80 rather
   than the phone bar's 56.

   It matters most in the 861-1240 band, where the rail is not a rail at all
   but an inline row that scrolls away with the page. There the header is the
   only navigation left once you are reading, and these five links are how you
   move between sections; landing each heading behind the bar would make the
   rail worse than useless exactly where it is already weakest. */
.section[id] { scroll-margin-top: 100px; }

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

/* MOVED: the hero is now a three-column grid rather than a centred block.
   The CTA sits in the right column instead of on its own line below the
   title, so it costs no vertical space and the title can sit lower again.

   padding-inline constrains the hero to --w-wide, the figure's measure, so
   the CTA lands exactly on the image's right edge rather than the shell's.
   1fr on both flanks keeps the title centred on the page, not centred in
   whatever space the CTA leaves over.

   padding-top went 216 -> 56 -> 36 as the date line and Details block were
   removed and the CTA was added inline; with the CTA out of the flow it
   returns to 60, which is the "text moves down" part of the change. */
.case-hero {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  column-gap: 20px;
  padding-top: 60px;
  padding-inline: max(0px, calc((100% - var(--w-wide)) / 2));
  text-align: center;
}

.case-hero__title { grid-column: 2; }

/* MOVED: was a centred line under the title. */
.case-hero__cta {
  grid-column: 3;
  justify-self: end;
  margin: 0;
}

/* Below this the flanking columns are narrower than the CTA, so the title
   would stop being centred. Stack instead: title, then CTA beneath it. */
@media (max-width: 1000px) {
  .case-hero {
    grid-template-columns: 1fr;
    padding-inline: 0;
  }
  .case-hero__title,
  .case-hero__cta { grid-column: 1; }
  .case-hero__cta { justify-self: center; margin-top: 18px; }
}

/* margin-top was 44px, the gap under the removed date line. Nothing sits
   above the title now, so the hero's own padding is the only offset. */
/* --- Sora 700, and the face is named here ---------------------------------
   body is SF, so a heading that does not name a family inherits SF without
   saying so — the same trap the three mark components carry a note about in
   site.css. The stack is stated on this rule and on .section__title below,
   which are the two headings a case study sets in Sora. Nothing else on the
   site uses it, so there is no shared token to reach for.

   --font-sora is the token, declared beside --font-sf in site.css; see the note
   there for why it falls back to the SF chain rather than to a generic sans.

   Weight goes 400 to 700 with the face. Sora's 400 is noticeably lighter than
   SF's at this size and the title would have read as a caption. */
.case-hero__title {
  margin-top: 0;
  font-family: var(--font-sora);
  font-size: var(--fs-case-title);
  font-weight: 700;
  line-height: 1.15;
  text-transform: uppercase;

  /* -0.021em before, and caps is why it moved. That value was drawn for
     mixed-case SF, where negative tracking closes the gaps that ascenders and
     descenders open up. Caps have neither: every glyph is the same height and
     sits on the same two lines, so the same negative value reads as the
     letters jammed together instead of as tight setting. Positive, but barely
     — this is a 48px display line, where caps need only the negative taken
     off rather than real spacing added. The small labels below run 0.05 and
     0.08, which is the other end of the same rule: the smaller the caps, the
     more air they want. */
  letter-spacing: 0.01em;
}

/* masked line, same mechanic as the home hero */
.case-hero__title .line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
}
.case-hero__title .line > span { display: block; }

/* --- Figures ------------------------------------------------------------- */

/* 120px before. The hero figure is the first thing a case study has to show,
   so it sits close under the title rather than a screen below it. Figures
   further down the page keep their own rhythm via the adjacent-sibling rule. */
.figure { margin-top: 18px; }
.figure + .figure { margin-top: 44px; }
/* deeper figures still want air above them; only the hero one is tightened */
.statement + .figure,
.section + .figure { margin-top: 64px; }

.figure__media {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: var(--tint, var(--panel));
}
/* Direct children only. An ordinary figure holds one screenshot that should
   fill the box, and every one of those is a direct child. Anything NESTED is
   deliberately sized by its own component: .device__screen is positioned at
   the frame's aperture (91.3636% x 95.4148%, derived from the SVG's own
   coordinates and documented at the .device block in site.css), and a store
   badge is sized by its own rule below. Without the child combinator this
   rule is (0,1,1) and wins over both, stretching the screenshot over the
   bezel and blowing the badges up to full column width. */
.figure__media > img { width: 100%; height: auto; }

/* A phone flanked by the two store badges.
   No panel behind it: .figure__media paints --tint/--panel by default, which
   put a visible rectangle around a phone that is already its own shape. The
   other figures on the page are screenshots that need a surface to sit on;
   this one is a cut-out device and reads better straight on the page.
   align-items: center is what puts the badges on the phone's centre line. */
.figure--phone .figure__media {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(18px, 4vw, 56px);
  padding-block: 40px;
  background: none;
  border-radius: 0;
  overflow: visible;
}

/* .device is sized from the outside and derives its other axis from its own
   aspect-ratio, so width is all that is set. Capped hard: .m-mid is a 960px
   column and a phone filling it would tower over every other figure. */
.figure--phone .device { width: clamp(240px, 24vw, 320px); }

/* Both badges to one HEIGHT, not one width. The official artwork is
   proportioned differently on purpose (Apple 119.66x40, Google 180x53.33, so
   Google is wider at any shared height) and matching heights is how both
   vendors' guidelines say to place them together. */
.store-badge {
  position: relative;                /* the cue anchors to this */
  flex: none;
  display: block;
  /* Written by buildStoreProximity() in site.js as the pointer approaches.
     Declared here so the CSS is complete on its own: with no JS, no GSAP or
     reduced motion on, --near stays 0 and the :hover rule below is what shows
     the cue. The proximity code only makes it wake up earlier. */
  --near: 0;
  transition: transform 0.35s var(--ease);
}
.store-badge img {
  display: block;
  width: auto;
  height: clamp(32px, 3vw, 40px);
}

/* A lift, not a fade. Dimming the badge at rest so it brightens on approach
   was the obvious version, but both Apple's and Google's identity guidelines
   forbid altering the badge artwork, and a resting opacity is an alteration.
   The lift and the cue carry the affordance instead. */
/* Hover gated: see the note on .nav a:hover::before in site.css. */
@media (hover: hover) {
  a.store-badge:hover { transform: translateY(-2px); }
}

/* Absolute, so fading the cue in can never move the badges or the phone.
   nowrap for the same reason: a second line would change nothing in layout,
   but it would collide with the bezel at narrow widths. */
.store-badge__cue {
  position: absolute;
  top: calc(100% + 12px);
  left: 50%;
  transform: translateX(-50%);
  white-space: nowrap;
  color: var(--ink);
  font-size: var(--fs-chip);
  line-height: 1.15;
  pointer-events: none;
  opacity: var(--near, 0);
  transition: opacity 0.3s var(--ease);
}

/* Hover and keyboard focus are the guarantee; --near is the enhancement.
   (0,2,0) against the base rule's (0,1,0), so these always win over whatever
   the proximity code last wrote. */
.store-badge:focus-visible .store-badge__cue { opacity: 1; }
@media (hover: hover) {
  .store-badge:hover .store-badge__cue { opacity: 1; }
}

/* Deliberately NOT mirroring .demo__cue, which stays up on every pointer once
   its figure is armed. That is the one place these two components part company.

   .demo__cue has to be visible because its trigger is an invisible overlay on
   a screenshot: without the label there is nothing telling anyone it can be
   pressed. A store badge is the opposite — it is a universally understood
   button. So --near simply stays 0 (buildStoreProximity returns early without
   a fine pointer) and the cue never shows on touch. Pinning it up would print
   "See the live listing" twice under two adjacent badges, on top of a
   figcaption already saying the same thing. The figcaption is the touch and
   no-JS fallback. */

/* Stacked under the phone once the row stops fitting: at .m-mid the three
   items need ~690px, and below that the badges would start crowding the
   bezel. */
@media (max-width: 760px) {
  .figure--phone .figure__media {
    flex-wrap: wrap;
    gap: 28px;
  }
  .figure--phone .device { order: -1; flex-basis: 100%; margin-inline: auto; }
}

.figure figcaption {
  margin-top: 16px;
  color: var(--ink-soft);
  font-size: var(--fs-chip);
}

/* The Details block that used to sit here — a Role / Timeline / Focus
   definition list under the hero figure — has been removed from the template
   and from every case study, along with its styles. The same facts are
   carried by the running copy. */

/* --- Demo player ---------------------------------------------------------
   A case-study figure that turns into its own video. Used where the product
   is internal and there is no live URL to send anyone to.

   The <img> stays in the DOM and keeps sizing the box; the video is absolute
   inside it. So the frame is decided by the still, the video can only fill
   it, and starting playback moves nothing on the page. object-fit: cover
   absorbs the ~0.2% ratio difference between the 1800x1023 still and the
   1269x720 encode, and crops any letterboxing the encoder baked in rather
   than showing bars. .figure__media already clips with overflow: hidden, so
   the video takes the same rounded corners.
   ------------------------------------------------------------------------- */

.demo__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: 0;
  pointer-events: none;
  /* opacity only: stays on the compositor, so the swap cannot trigger a
     reflow halfway through and jitter the frame */
  transition: opacity 0.4s var(--ease);
}

.demo__trigger {
  position: absolute;
  inset: 0;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  color: #fff;
  font: inherit;
  transition: opacity 0.3s var(--ease);
}

/* Scrim lives on the trigger, not as a filter over the image, so the
   screenshot underneath never blurs into unreadability. */
.demo__trigger::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(10, 10, 12, 0.42);
  opacity: 0;
  transition: opacity 0.3s var(--ease), background-color 0.3s var(--ease);
}

.demo__cue {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  padding: 11px 18px;
  border-radius: 100px;
  background: rgba(10, 10, 12, 0.72);
  -webkit-backdrop-filter: blur(6px);
          backdrop-filter: blur(6px);
  font-size: var(--fs-chip);
  line-height: 1.15;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.3s var(--ease);
}

/* --- Blueprint -----------------------------------------------------------
   A two-lane swimlane: time across, actors down. Inlined rather than loaded
   through <img> so the text inherits the page face and every stroke resolves
   from the same tokens as everything else, in both themes.

   Wide by nature. Legible type at a 390px viewport would leave about five
   characters per column, so the figure scrolls sideways inside its own box
   rather than shrinking past readability or pushing the page out.
   ------------------------------------------------------------------------- */

.blueprint {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
}
.blueprint > .diagram { min-width: 720px; }

.diagram {
  display: block;
  width: 100%;
  height: auto;
  font-family: inherit;
}

.diagram .d-box {
  fill: none;
  stroke: var(--rule);
  stroke-width: 2;
}
.diagram .d-rule  { stroke: var(--rule); stroke-width: 2; }
.diagram .d-ink   { fill: var(--ink); }
.diagram .d-soft  { fill: var(--ink-soft); }
.diagram .d-accent   { fill: var(--pill-accent); }
.diagram .d-accent-t { fill: var(--pill-accent); }

/* the time track: dashed while there is no connection, solid once there is */
.diagram .d-track {
  stroke: var(--ink-soft);
  stroke-width: 2;
}
.diagram .d-track--off { stroke-dasharray: 3 9; stroke-linecap: round; }

/* the moment the radio comes back, dropped through both lanes */
.diagram .d-seam {
  stroke: var(--pill-accent);
  stroke-width: 1.5;
  stroke-dasharray: 2 8;
  opacity: 0.55;
}

.diagram .d-wire {
  fill: none;
  stroke: var(--ink-soft);
  stroke-width: 2;
}
.diagram .d-head {
  fill: none;
  stroke: var(--ink-soft);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* --- Phone pair ----------------------------------------------------------
   A hero for an app that only exists on a phone. Two devices side by side,
   sitting straight on the page with no figure panel behind them, the same way
   the Captains card does it on the home page.
   ------------------------------------------------------------------------- */

.phones__row {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  gap: clamp(20px, 4vw, 56px);
}

/* Two phones, so each takes about a third of the measure rather than half:
   at half they tower over the copy that follows. */
.phones .device { width: min(286px, 38vw); }

@media (max-width: 620px) {
  .phones .device { width: min(200px, 42vw); }
}

/* Three phones have to give width back. At the two-phone 38vw a third one
   overflows the viewport before 1000px, and at 286px the row measures 970
   against a 1080 column, which leaves no air at the ends. 240 x 3 plus two
   56px gaps is 832. */
.phones--three .device { width: min(240px, 27vw); }

@media (max-width: 620px) {
  .phones--three .device { width: min(150px, 27vw); }
}

/* Badges belong to the whole row here, not to one phone, so they sit beneath
   it instead of flanking it the way .figure--phone arranges them. Same
   .store-badge component and the same proximity cue; only the placement
   differs. The cue is absolutely positioned and clears roughly 28px, which
   fits inside .metrics' own 40px margin below. */
.phones__stores {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: clamp(18px, 4vw, 48px);
  margin-top: 40px;
  /* The cue is absolutely positioned, so it reserves no height of its own and
     ran into the metrics strip below with about 12px to spare. 34px is its
     12px offset plus a ~16px line, plus air. PADDING, not margin: a margin
     here collapses through the figure and .metrics' own 40px simply wins. */
  padding-bottom: 34px;
}

/* Flanking a phone, the cue sits at the vertical middle of a tall figure and
   has room underneath. In this row it is the last thing on the page before
   the metrics, so it gets a shorter drop. */
.phones__stores .store-badge__cue { top: calc(100% + 9px); }

/* --- Demo player, phone variant ------------------------------------------
   A portrait recording cannot fill a landscape figure, so it plays inside the
   iPhone frame the site already ships (.device in site.css). Everything is
   pinned to that frame's aperture rather than to the figure.
   ------------------------------------------------------------------------- */

.demo--phone .demo__stage {
  display: flex;
  justify-content: center;
}

/* min() so the phone shrinks with the viewport instead of overflowing it */
.demo--phone .device { width: min(400px, 76vw); }

/* The aperture, restated from .device__screen as insets: the base rule anchors
   with left/top/width/height, and .demo__video's own inset:0 would otherwise
   win here and let the video cover the bezel. Percentages are the same
   804x1748 window in the 880x1832 frame. */
/* Geometry restated from .device__screen, NOT expressed as insets. A <video>
   is a replaced element: with width/height auto it takes its intrinsic size
   and ignores the insets entirely, so the first cut rendered at the file's own
   720x1334 and pushed 390px of horizontal overflow onto the page. Explicit
   percentages are the only thing that pins it to the aperture. */
.demo--phone .demo__video {
  inset: auto;
  left: 4.3182%;                             /*   38 / 880  */
  top: 2.2926%;                              /*   42 / 1832 */
  width: 91.3636%;                           /*  804 / 880  */
  height: 95.4148%;                          /* 1748 / 1832 */
  /* contain, not cover: the recording is 0.54 against a 0.46 aperture, so
     cover would crop 15% of the width and cut the app's own chrome off.
     Contained it leaves a thin band top and bottom, which is invisible
     because the app it is showing is white to the edges. */
  object-fit: contain;
  background: #fff;
  -webkit-mask: url(../images/device/iphone-17-pro-screen.svg) center / 100% 100% no-repeat;
          mask: url(../images/device/iphone-17-pro-screen.svg) center / 100% 100% no-repeat;
}

/* Two steps, and the arming is the point: until "See demo" is pressed the
   figure is an ordinary screenshot with no hover state and no focus stop, so
   nothing competes with the copy. The CTA arms it, and only then does the
   frame offer to play.

   Arming shows the scrim and the cue outright — it does NOT wait for a hover.
   Gating them on :hover meant pressing "See demo" appeared to do nothing: the
   reader's pointer is up on the CTA at that moment, not down on the figure, so
   the one piece of feedback confirming the press had landed was hidden exactly
   when it was needed. It also made the second step undiscoverable — you had to
   already suspect the image was clickable to find out that it was. Now the
   press dims the frame and puts "Click to view tutorial" on it, and the reader
   can take their time scrolling to it.

   Hover still answers, by deepening the scrim rather than introducing it, so
   the frame reads as a live control without the cue depending on the pointer.
   That also drops the old `@media (hover: none)` special case: touch and
   pointer now get the same armed state, which is what it was emulating. */
.demo.is-armed .demo__trigger::before,
.demo.is-armed .demo__cue { opacity: 1; }

.demo.is-armed .demo__trigger:focus-visible::before {
  background: rgba(10, 10, 12, 0.58);
}
/* Hover gated: see the note on .nav a:hover::before in site.css. The mildest
   case on the site, since the scrim is already up and hover only deepens it,
   but a tapped frame kept the deeper value with no pointer to move away. */
@media (hover: hover) {
  .demo.is-armed .demo__trigger:hover::before {
    background: rgba(10, 10, 12, 0.58);
  }
}

.demo.is-playing .demo__video {
  opacity: 1;
  pointer-events: auto;
}
.demo.is-playing .demo__trigger {
  opacity: 0;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .demo__video,
  .demo__trigger,
  .demo__trigger::before,
  .demo__cue { transition: none; }
}

/* --- Pull statement ------------------------------------------------------ */

.statement {
  margin-top: 100px;
  font-size: var(--fs-statement);
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: -0.016em;
  text-align: center;
  text-wrap: balance;
}

/* --- Live CTA ------------------------------------------------------------
   One action per case study, placed against the metrics rather than at the
   foot of the page: most readers never reach the end, and the strip is the
   last thing they are guaranteed to look at.

   Filled, not the site's default .btn. That chip rests on --chip-bg, which
   measures 1.15:1 against --bg in dark and 1.13:1 in light — legible label,
   invisible control. An action nobody can find is not an action, so this one
   takes the same high-contrast treatment as the home page's primary.

   In the hero, directly under the title. The foot of the page and the metrics
   strip were both tried and both sit below the fold at a 813px viewport; a
   link whose entire job is to be clicked cannot depend on scrolling. It
   inherits the hero's centred axis, so no alignment of its own is needed.

   Height is 46px at the default type size, clearing the 44px minimum target
   in WCAG 2.5.5. Focus styling comes from the global :focus-visible rule.
   ------------------------------------------------------------------------- */

/* Sized against the title it sits beside, not against the body text. At
   --fs-ui and 13px padding it stood 46px tall next to a 55px heading, so the
   secondary action read as heavy as the page title. --fs-chip and tighter
   padding bring it to ~38px, about two thirds of the heading. */
/* Used on both an <a> (leaves the site) and a <button> (plays a demo in
   place). The reset block is what makes the two identical: a bare <button>
   otherwise arrives with the UA's Arial, a 2px outset border and
   appearance:auto, which rendered it 199x42 next to the anchor's 153x38.
   Every one of these is a no-op on the anchor. */
.case-cta__link {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 11px 18px;
  border: 0;
  border-radius: 100px;
  -webkit-appearance: none;
          appearance: none;
  cursor: pointer;
  background: var(--ink);
  color: var(--ink-invert);
  font-family: inherit;
  font-weight: inherit;
  font-size: var(--fs-chip);
  line-height: 1.15;
  white-space: nowrap;
  transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}

/* The fill is already the strongest value on the page, so hover cannot go
   brighter — it lifts and softens instead, which keeps the two states
   distinguishable without inventing a second colour. */
/* Hover gated: see the note on .nav a:hover::before in site.css. Visit
   spacez.co opens a new tab, so the reader comes back to this one with the
   lift still applied and nothing to clear it. */
@media (hover: hover) {
  .case-cta__link:hover {
    opacity: 0.88;
    transform: translateY(-2px);
  }
}
.case-cta__link:active { transform: translateY(0); }

/* Marks the link as leaving the site. Inherits currentColor, so it tracks the
   label in both themes and needs no separate dark-mode value. */
.case-cta__arrow {
  display: block;
  flex: 0 0 auto;
  transition: transform 0.3s var(--ease);
}
@media (hover: hover) {
  .case-cta__link:hover .case-cta__arrow { transform: translate(1.5px, -1.5px); }
}



/* --- Prose sections ------------------------------------------------------ */

.section { margin-top: 88px; }

/* Sora 700, the same pair as .case-hero__title — see the note there for why
   the face is named rather than inherited, and why the fallbacks are SF.

   --fw-heading-sm is dropped rather than overridden. The token exists to keep
   the small headings on one weight across the site, and this heading is no
   longer on that scale: it is the second half of a two-item Sora set with the
   page title, and 700 is what pairs with it. /about/'s .row__label and
   /resume/'s section headings still carry the token and are untouched. */
.section__title {
  font-family: var(--font-sora);
  font-size: var(--fs-section);
  font-weight: 700;
  line-height: 1.35;
  text-transform: uppercase;
  /* 0.05, against the title's 0.01 above. Same rule, further along it: these
     run 19 to 22px where the title runs 48, and caps want more air the smaller
     they get. The 620 block takes it to 0.08 at 14px for the same reason. */
  letter-spacing: 0.05em;
}

.section__body {
  margin-top: 26px;
  font-size: var(--fs-prose);
  font-weight: var(--fw-body);
  line-height: 1.65;
  text-wrap: pretty;
}

/* Emphasis in running prose — the numbers and the one ownership claim a
   skimmer has to catch.

   600, not the browser default. Body prose is --fw-body, and there was no `b`
   rule here at all, so every bold in these pages was falling through to 700 —
   a jump wide enough that the phrase reads as a heading dropped into the
   middle of a sentence.

   The pair survived these pages moving from Montserrat to SF, and got slightly
   stronger for it: SF steps 400 to 600 across a 4.8% width change against
   Montserrat's 350 to 600 at 3.9%, and both are real drawn instances rather
   than synthesised ones. 600 is still the weight that reads as emphasis rather
   than as an interruption.

   Weight is the only lever available: .section__body inherits --ink with no
   softening, so there is no tonal headroom to tint into. */
.section__body b { font-weight: 600; }
.section__body p + p { margin-top: 22px; }

.section__body a,
.link-underline {
  text-decoration: underline;
  text-underline-offset: 5px;
  text-decoration-thickness: 1px;
  transition: opacity 0.3s var(--ease);
}
@media (hover: hover) {
  .section__body a:hover,
  .link-underline:hover { opacity: 0.55; }
}

/* --- Outcome metrics ----------------------------------------------------- */

/* Results strip. It sits directly under the hero figure now, not after the
   Outcome section — the numbers were three screens down, where a reader who
   doesn't scroll never met them.

   The old treatment was one --panel fill on --bg, which measures 1.06:1 in
   dark and 1.09:1 in light: no visible container, and three or four values
   floating inside it with nothing between them.

   So the structure carries the separation instead of tone. 1px of grid gap
   and 1px of padding, container painting --rule, each cell painting --panel
   over it. The container shows through only in those gaps, which draws the
   dividers and the outer border in one pass — and it survives any column
   count and any wrap, which matters because these blocks carry 3 or 4 figures
   depending on the project. auto-fit keeps them filling the row evenly. */
.metrics {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 1px;
  padding: 1px;
  margin-top: 32px;
  border-radius: 14px;
  overflow: hidden;
  background: var(--rule);
}

.metrics > div {
  padding: 30px 26px;
  background: var(--panel);
}

.metrics__value {
  font-size: clamp(27px, 2.4vw, 35px);
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: -0.022em;
  /* digits share one advance, so values line up cell to cell */
  font-variant-numeric: tabular-nums;
}

/* 10px before — the value and its label are one unit, and the gap between
   cells should be the larger of the two distances, not the smaller. */
.metrics__label {
  margin-top: 6px;
  color: var(--ink-soft);
  font-size: var(--fs-chip);
  line-height: 1.45;
  text-wrap: pretty;
}

/* --- Pills --------------------------------------------------------------- */

.pill {
  display: inline-block;
  padding: 7px 15px;
  border-radius: 100px;
  background: var(--pill-bg);
  color: var(--chip-ink);
  font-size: var(--fs-chip);
  line-height: 1.15;
  white-space: nowrap;
}
.pill--accent {
  background: var(--pill-accent);
  color: #fff;
}

/* --- Before / redesigned comparison -------------------------------------- */

.compare {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 22px;
  margin-top: 44px;
}

.compare__col {
  padding: 34px 30px 30px;
  border-radius: 14px;
  background: var(--panel);
  text-align: center;
}

.compare__body {
  margin-top: 44px;
  text-align: left;
}

.compare__caption {
  margin-top: 34px;
  font-size: var(--fs-chip);
  color: var(--ink);
}


/* --- Stepped flow --------------------------------------------------------

   Rebuilt on the principle the .metrics note above already argues for, and
   that .row in page.css already uses: structure carries the grouping, tone
   does not.

   The original treatment filled every step with --panel-2 and put the band
   label in a bracket on the right. Both failed here. --panel-2 is #ffffff in
   light against a #ffffff page, so the container measured 1.00:1 and the
   steps read as unstyled text; and once the grid collapsed on a phone the
   right-hand label fell BELOW its own steps, where it looks like a heading
   for the group that follows.

   So: no fill, hairlines for separation, and the label moves to the same
   168px left gutter the About page uses. Weight comes down to --fw-ui, since
   600 was heavier than anything else on the page. */

.flow { display: block; }

/* One band: label in the gutter, its steps in the content column. Placement
   is explicit because the label follows the list in source order (it reads
   better that way for a screen reader) but precedes it visually. */
.flow__group {
  display: grid;
  grid-template-columns: var(--label-col, 168px) 1fr;
  column-gap: var(--gap, 40px);
  row-gap: 10px;
  padding-top: 24px;
  border-top: 1px solid var(--rule);
}
.flow__group + .flow__group { margin-top: 30px; }

.flow__group > ol,
.flow__group > ul { grid-column: 2; grid-row: 1; }

.flow__label {
  grid-column: 1;
  grid-row: 1;
  color: var(--pill-accent);
  font-size: 12px;
  line-height: 1.5;
  letter-spacing: 0.08em;
  white-space: nowrap;
}

/* Steps: hairline-separated rows. The first has none, because the band it
   sits in already drew one. */
.flow__item { padding: 13px 0; }
.flow__item + .flow__item { border-top: 1px solid var(--rule); }
.flow__group .flow__item:first-child { padding-top: 0; }

.flow__item b {
  display: block;
  font-size: 16px;
  font-weight: var(--fw-ui);
  letter-spacing: -0.005em;
}
.flow__item span {
  display: block;
  margin-top: 5px;
  font-size: var(--fs-chip);
  line-height: 1.5;
  color: var(--ink-soft);
}
/* the tier that exists but is rarely reached, dimmed rather than removed */
.flow__item--muted { opacity: 0.5; }

/* The 168px gutter costs more than it buys once the content column drops
   below roughly 40em, same threshold page.css uses for .row. The label
   stacks ABOVE its steps, never below. */
@media (max-width: 860px) {
  .flow__group {
    grid-template-columns: 1fr;
    row-gap: 12px;
    padding-top: 20px;
  }
  .flow__group > ol,
  .flow__group > ul { grid-column: 1; grid-row: 2; }
  .flow__label { grid-column: 1; grid-row: 1; }
  .flow__group + .flow__group { margin-top: 24px; }
}

/* --- Option / variant cards ---------------------------------------------- */

.option {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: center;
  gap: 40px;
  padding: 34px;
  border-radius: 14px;
  background: var(--panel);
  margin-top: 60px;
}

.option__shot {
  overflow: hidden;
  border-radius: 8px;
  background: var(--tint, var(--media-frame));
}
.option__shot img { width: 100%; height: auto; }

.option__note {
  margin-top: 22px;
  font-size: clamp(17px, 1.36vw, 20px);
  font-weight: 600;
  line-height: 1.4;
  letter-spacing: -0.012em;
}

.option-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 22px;
  margin-top: 22px;
}

.option-grid > li {
  padding: 20px;
  border-radius: 14px;
  background: var(--panel);
}
.option-grid figcaption,
.option-grid p {
  margin-top: 18px;
  font-size: var(--fs-chip);
  line-height: 1.5;
  color: var(--ink);
}

/* --- Checklist ----------------------------------------------------------- */

.checklist {
  margin-top: 20px;
  font-size: var(--fs-chip);
  line-height: 1.6;
}
.checklist li {
  display: flex;
  align-items: baseline;
  gap: 9px;
}
.checklist li + li { margin-top: 6px; }

.checklist .yes,
.checklist .no {
  flex: none;
  width: 13px;
  height: 13px;
  align-self: center;
}
.checklist .yes { color: var(--ok); }
.checklist .no { color: var(--ink-soft); }
.checklist li.is-no { color: var(--ink-soft); }

/* --- More projects ------------------------------------------------------- */

.more {
  margin-top: 100px;
  padding-bottom: 96px;
}

.more__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 24px;
}
.more__head h2 {
  font-size: var(--fs-section);
  font-weight: var(--fw-heading-sm);
  letter-spacing: -0.012em;
}

.more__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 24px;
  margin-top: 40px;
}

.more__grid .card__title { margin-top: 20px; }

/* --- Responsive ---------------------------------------------------------- */

@media (max-width: 1240px) {
  .case-hero { padding-top: 60px; }
}

@media (max-width: 860px) {
  :root {
    --fs-case-title: clamp(30px, 6.2vw, 40px);
    --fs-statement: 22px;
    --fs-section: 19px;
    --fs-prose: 17px;
  }

  .figure { margin-top: 28px; }
  .statement + .figure,
  .section + .figure { margin-top: 48px; }
  .statement { margin-top: 56px; }
  .section { margin-top: 56px; }
  .more { margin-top: 64px; padding-bottom: 56px; }

  /* One column, not auto-fit. Below this width auto-fit wraps a 4-figure
     block onto 3 + 1, and because the dividers are the container showing
     through the grid gaps, the leftover track renders as a lit empty cell.
     A single column divides cleanly whether the block carries 3 or 4. */
  .metrics { grid-template-columns: 1fr; }

  .compare,
  .option-grid,
  .more__grid {
    grid-template-columns: 1fr;
  }
  .option {
    grid-template-columns: 1fr;
    gap: 24px;
  }
}

@media (max-width: 620px) {
  .compare__col { padding: 24px 18px; }
  .flow__group {
    grid-template-columns: 1fr;
    row-gap: 8px;
  }
  .flow__label {
    padding-left: 0;
    border-left: 0;
  }
}

/* ==========================================================================
   Mobile type scale
   Case-study half of the change documented at the end of page.css. The two
   files are never loaded together, so each owns its own tokens and the same
   reasoning has to be stated in both places rather than shared.
   ------------------------------------------------------------------------- */

@media (max-width: 620px) {
  :root {
    --fs-prose: 16px;
    --fs-section: 14px;
    --fs-statement: 19px;
  }

  /* The "more work" heading becomes a label, matching .row__label on /about/
     and /resume/. On these pages it also lines the heading up with the TOC
     rail's own register. */
  .more__head h2 {
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--ink-soft);
    font-weight: var(--fw-ui);
  }

  /* .section__title used to be in that rule and has come out of it.
     ------------------------------------------------------------------------
     All four declarations were doing the label conversion, and three of them
     now contradict the page: the headings are Sora 700 in ink at every width,
     which is the whole point of the face being there, and greying them to
     --ink-soft at --fw-ui below 620 turned them back into captions on the one
     screen size where the reader most needs to find them. text-transform is
     redundant too — the base rule capitalises them now.

     The tracking is the one part that was never about the label look: 0.05em
     is drawn for 19-22px caps and these are 14px here. It stays, on its own. */
  .section__title { letter-spacing: 0.08em; }

  .section__body { line-height: 1.55; }
  .section__body li { line-height: 1.5; }
}

/* ==========================================================================
   Mobile restructuring: metrics, before/after, section rail
   ========================================================================== */

@media (max-width: 620px) {
  /* --- Metrics, two up ---------------------------------------------------
     The strip ran 463-520px on a phone, one figure per row, which is 1.6
     screens and 7-10% of the whole page spent on four numbers. Numbers are
     scan targets, not prose: a 2x2 block is read at a glance where a column is
     read one at a time.

     The single column above this was NOT arbitrary — see the note at the
     --fs-case-title block. Its argument is that auto-fit wraps a 3-figure
     strip onto 2+1, and because the dividers are the container showing through
     the grid gaps, the leftover track renders as a lit empty cell.

     So the two-column rule is quantity-gated rather than unconditional: it
     applies only to a strip of exactly four. All four case studies carry four
     today, and a future three-figure strip keeps the clean single column
     instead of growing an empty cell. Browsers without :has() also fall back
     to the single column, which is the behaviour shipping today. */
  .metrics:has(> div:nth-child(4)):not(:has(> div:nth-child(5))) {
    grid-template-columns: repeat(2, 1fr);
  }
  /* The cell is ~173px wide at two up, so the desktop 30/26 inset would leave
     about 121px of content. */
  .metrics > div { padding: 20px 16px; }
  .metrics__value { font-size: 22px; }

  /* --- Before / after, swiped not stacked --------------------------------
     .compare stacked its two columns here, which made it 841px on Edge and
     900px on the Console: a full screen of vertical scrolling for a
     comparison whose entire point is that you read it ACROSS. Horizontal is
     the only axis that preserves that on a phone.

     84% rather than 100% deliberately — the sliver of the next card is what
     tells a reader there IS a next card. A full-width snap looks like a
     static block and never gets swiped.

     No negative margins: the scroller stays inside the content column, so it
     cannot widen the page. Scrollbar hidden because the peeking card is the
     affordance and a scrollbar under two cards reads as a third element. */
  .compare {
    display: flex;
    grid-template-columns: none;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    gap: 12px;
    scrollbar-width: none;
  }
  .compare::-webkit-scrollbar { display: none; }

  .compare__col {
    flex: 0 0 84%;
    scroll-snap-align: start;
    padding: 24px 20px 20px;
  }
  .compare__body { margin-top: 28px; }
  .compare__caption { margin-top: 24px; }
}

@media (max-width: 860px) {



  /* The bar is 55px tall and pinned, so an anchor jump parks its target at
     y=0 — behind it. Measured: tapping Approach, Shipping or Reflection put
     every heading at top 0 with the bar's bottom at 55, so the rail scrolled
     you to a section whose title you could not see. scroll-margin-top is the
     property for exactly this: it pads the SCROLL target without adding any
     layout space. 70 is the bar plus a cell of air. */
  .section[id] { scroll-margin-top: 70px; }
}
