
/* ===========================================================================
   Case study — editorial template
   Type system matches the homepage: Fraunces for display, Instrument Sans for
   body and navigation, IBM Plex Mono for labels and data. Same paper / ink /
   signal palette. Fraunces is loaded at 340 / 420 / 520 (plus italics), so
   display weights stay on those instances rather than synthesising.
   =========================================================================== */
:root{
  --paper:#FAF9F5; --paper-alt:#F1EEE6; --ink:#15140F; --ink-soft:#6B6759;
  --line:#DFDBCF; --line-strong:#15140F; --signal:#1E3AE0;

  --serif:'Fraunces', Georgia, serif;
  --sans:'Instrument Sans', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
  --mono:'IBM Plex Mono','SFMono-Regular',monospace;

  --gutter: clamp(1.25rem, 4vw, 4rem);
  --maxw: 1350px;
  --measure: 68ch;
  --header-h: 76px;

  /* vertical rhythm */
  --s-sm: clamp(1rem, 1.4vw, 1.6rem);
  --s-md: clamp(1.8rem, 3vw, 3.2rem);
  --s-lg: clamp(3rem, 6vw, 6.5rem);
  --s-xl: clamp(4.5rem, 10vw, 11rem);
  /* The space a chapter keeps above and below itself. Separate from --s-xl,
     which also sets the hero, summary and closing-CTA spacing — the rhythm
     between chapters wants to be tighter than those without dragging them
     down with it. */
  --s-chapter: clamp(3rem, 6.5vw, 7rem);

  /* type scale */
  --t-hero: clamp(2.6rem, 6.4vw, 6.4rem);
  --t-title: clamp(2.625rem, 5vw, 5.625rem);
  --t-statement: clamp(2.25rem, 5.4vw, 5.6rem);
  --t-body: clamp(1.0625rem, 1.05vw, 1.3125rem);
  --t-caption: clamp(0.75rem, 0.85vw, 0.875rem);
  --t-label: clamp(0.7rem, 0.78vw, 0.78rem);

  --ease: cubic-bezier(.22,.61,.36,1);
}

/* Mobile keeps the same hierarchy — display still dominates body — but the
   display floor comes down so headlines don't run to five or six lines. */
@media (max-width:640px){
  :root{
    --t-hero: clamp(2.5rem, 11vw, 3.6rem);
    --t-title: clamp(2rem, 8.5vw, 2.9rem);
    --t-statement: clamp(1.85rem, 8vw, 2.6rem);
    --measure: 100%;
  }
  .cs-hero-inner--mid .cs-hero-title{ font-size:clamp(2.6rem, 11.3vw, 3.7rem); }
  .cs-hero-inner--long .cs-hero-title{ font-size:clamp(2.3rem, 9.9vw, 3.25rem); }
}

*,*::before,*::after{ box-sizing:border-box; }
html{ scroll-behavior:smooth; -webkit-text-size-adjust:100%; }
body{
  margin:0; background:var(--paper); color:var(--ink);
  font-family:var(--sans); font-size:var(--t-body); line-height:1.6;
  font-weight:400; -webkit-font-smoothing:antialiased; overflow-x:hidden;
}
img,svg{ display:block; max-width:100%; }
a{ color:inherit; text-decoration:none; }
h1,h2,h3,h4,p,dl,dd,dt,blockquote,figure,figcaption{ margin:0; }
::selection{ background:var(--ink); color:var(--paper); }
:focus-visible{ outline:2px solid var(--signal); outline-offset:4px; }

.visually-hidden{
  position:absolute; width:1px; height:1px; padding:0; margin:-1px;
  overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0;
}
.skip-link{ position:absolute; left:-999px; top:0; background:var(--ink); color:var(--paper); padding:0.8rem 1.2rem; z-index:200; }
.skip-link:focus{ left:1rem; top:1rem; }

/* --- entrance animation system -------------------------------------------
   One vocabulary, applied by [data-anim]. Only transform + opacity, so every
   entrance stays on the compositor. Distances and timings are deliberately
   restrained: heading 30px, body 20px, media scale .985. */
.js [data-anim]{
  opacity:0; will-change:transform,opacity;
  transition:opacity .72s var(--ease), transform .72s var(--ease);
}
.js [data-anim="head"]{ transform:translate3d(0,30px,0); }
.js [data-anim="body"]{ transform:translate3d(0,20px,0); transition-duration:.62s; }
.js [data-anim="media"]{ transform:scale(.985); opacity:.75; transition-duration:.8s; }
.js [data-anim].is-in{ opacity:1; transform:none; }
.js [data-anim="body"].is-in{ transition-delay:.08s; }
/* Held back on arrival, so the first screen is the title and statement alone.
   The resting states above are tuned for an element about to appear a moment
   later — media in particular rests at .75, which reads as visible rather than
   absent. Anything deliberately withheld goes to a true zero instead. */
.js [data-anim].is-held{ opacity:0; }

@media (prefers-reduced-motion: reduce){
  html{ scroll-behavior:auto; }
  *{ animation-duration:.001ms !important; transition-duration:.001ms !important; }
  [data-anim]{ opacity:1 !important; transform:none !important; }
  .chapter__panel{ position:static !important; transform:none !important; opacity:1 !important; }
  .chapter__aside-inner{ position:static !important; }
  /* Nothing pins, so the scroll runway would only leave dead space. */
  .chapter__runway{ display:none; }
}

/* --- reading progress ---------------------------------------------------- */
.cs-progress{ position:fixed; top:0; left:0; right:0; height:2px; z-index:120; background:transparent; pointer-events:none; }
.cs-progress span{ display:block; height:100%; width:100%; background:var(--ink); transform:scaleX(0); transform-origin:0 50%; }

/* --- header -------------------------------------------------------------- */
.site-header{ position:fixed; top:0; left:0; right:0; z-index:100; padding:1.4rem 0; background:rgba(250,249,245,0.9); backdrop-filter:blur(12px); -webkit-backdrop-filter:blur(12px); border-bottom:1px solid transparent; transition:transform .5s var(--ease), border-color .4s ease; }
.site-header.is-stuck{ border-bottom-color:var(--line); }
.header-inner{ max-width:var(--maxw); margin:0 auto; padding:0 var(--gutter); display:flex; align-items:center; justify-content:space-between; }
.site-header.hide{ transform:translateY(-110%); }
.logo{ font-weight:600; font-size:0.95rem; letter-spacing:-0.01em; }
.main-nav{ display:flex; align-items:center; gap:2.1rem; }
.main-nav a{ font-size:0.9rem; font-weight:500; }
/* Three destinations rather than two, so the row gives ground on a narrow
   screen before the wordmark starts wrapping onto a second line. */
@media (max-width:560px){
  .logo-name{ white-space:nowrap; }
  .logo{ font-size:0.85rem; }
  .main-nav{ gap:1rem; }
  .main-nav a{ font-size:0.8rem; }
}
/* Narrower than this the name and three links cannot share a line at a size
   worth reading, so the sparrow carries the brand on its own. */
@media (max-width:340px){ .logo-name{ display:none; } }

/* --- password gate -------------------------------------------------------- */
/* Deliberately plain: a form, not a security theatre. It sits where the key
   visual would be, so the page reads as a case study that is simply closed. */
.cs-lock{ padding:var(--s-lg) var(--gutter) var(--s-xl); }
.cs-lock-form{ max-width:34rem; margin:0 auto; padding:var(--s-md);
  border:1px solid var(--line); background:var(--paper-alt); }
.cs-lock-label{ display:block; font-size:var(--t-body); line-height:1.5; margin-bottom:var(--s-sm); }
.cs-lock-row{ display:flex; gap:0.6rem; flex-wrap:wrap; }
.cs-lock-input{ flex:1 1 12rem; min-width:0; font-family:var(--mono); font-size:0.95rem;
  padding:0.8rem 0.9rem; border:1px solid var(--line); background:var(--paper);
  color:var(--ink); border-radius:0; }
.cs-lock-input:focus-visible{ outline:2px solid var(--signal); outline-offset:2px; }
.cs-lock-btn{ font-family:var(--sans); font-size:0.95rem; font-weight:500;
  padding:0.8rem 1.4rem; border:1px solid var(--ink); background:var(--ink);
  color:var(--paper); cursor:pointer; transition:opacity .25s ease; }
.cs-lock-btn:hover{ opacity:0.86; }
.cs-lock-btn[disabled]{ opacity:0.5; cursor:default; }
.cs-lock-note{ margin-top:var(--s-sm); font-size:0.9rem; color:var(--ink-soft); }
.cs-lock-note a{ border-bottom:1px solid var(--line); }
.cs-lock-note a:hover{ border-color:var(--ink); }

/* --- CaseHero ------------------------------------------------------------- */
/* The same grid texture the homepage and About carry. Full-bleed, because a
   max-width clips an element's background along with its content and would
   leave bare paper down each side on wide screens — the measure is held by the
   side padding instead. */
/* min-height gives the section a screen to centre within — its height was
   content-driven before, so there was no free space to distribute and
   align-items would have had nothing to do. svh rather than vh so a phone's
   collapsing URL bar does not make the section change height mid-scroll. */
.cs-hero{
  min-height:100svh;
  padding-block:calc(var(--header-h) + var(--s-md)) var(--s-lg);
  padding-inline:max(var(--gutter), calc((100% - var(--maxw)) / 2 + var(--gutter)));
  display:flex; align-items:center;
  background-image:
    linear-gradient(rgba(21,20,15,0.055) 1px, transparent 1px),
    linear-gradient(90deg, rgba(21,20,15,0.055) 1px, transparent 1px);
  background-size:32px 32px;
}
/* --hero-w is the shared measure: the title and the statement below it wrap to
   the same width, so the two read as one block rather than two ragged columns. */
.cs-hero-inner{ width:100%; --hero-w:58rem; }
.cs-hero-inner--mid{ --hero-w:70rem; }
.cs-hero-inner--long{ --hero-w:82rem; }
.cs-back{ display:inline-block; font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase; letter-spacing:0.12em; color:var(--ink-soft); margin-bottom:var(--s-md); border-bottom:1px solid transparent; transition:color .3s ease, border-color .3s ease; }
.cs-back:hover{ color:var(--ink); border-color:var(--ink); }
/* Back link and reading label share one line of orientation above the title:
   where you came from on the left, how long the read is on the right. The row
   carries the spacing so the link's own margin doesn't double it. */
.cs-hero-top{ display:flex; align-items:baseline; justify-content:space-between; gap:var(--s-sm); flex-wrap:wrap; margin-bottom:var(--s-md); }
.cs-hero-top .cs-back{ margin-bottom:0; }
/* In the signal blue, not ink-soft: it is the one piece of orientation in the
   hero a reader acts on, and at this size a grey hairline pill on grey paper
   disappeared into the grid behind it. */
.cs-read{ font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase;
  letter-spacing:0.12em; color:var(--signal); border:1px solid rgba(30,58,224,0.35);
  background:rgba(30,58,224,0.06); border-radius:999px; padding:0.3em 0.75em;
  line-height:1; white-space:nowrap; }
/* Titles arrive from the CMS at wildly different lengths, so the display size
   steps down as the title grows. A short title gets the full scale; a long one
   stays inside a few lines instead of swallowing the viewport. */
.cs-hero-title{
  font-family:var(--serif); font-size:var(--t-hero); font-weight:420; line-height:1.0;
  letter-spacing:-0.015em; max-width:var(--hero-w); text-wrap:balance;
}
.cs-hero-inner--mid .cs-hero-title{ font-size:clamp(2.5rem, 5.8vw, 5.8rem); line-height:1.02; }
.cs-hero-inner--long .cs-hero-title{ font-size:clamp(2.3rem, 5.2vw, 5.2rem); line-height:1.04; }
.cs-hero-statement{ margin-top:clamp(1.8rem,3.4vw,3.2rem); font-size:clamp(1.15rem,1.7vw,1.65rem); line-height:1.45; color:var(--ink-soft); max-width:var(--hero-w); text-wrap:pretty; }

/* ProjectMeta — a rail, not a boxed info card */
.cs-meta{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--s-md); margin-top:var(--s-lg); padding-top:var(--s-md); border-top:1px solid var(--line-strong); }
.cs-meta-key{ font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase; letter-spacing:0.12em; color:var(--ink-soft); margin-bottom:0.75rem; }
.cs-meta-val{ margin:0; font-size:clamp(0.95rem,1vw,1.05rem); line-height:1.45; }
@media (max-width:900px){ .cs-meta{ grid-template-columns:repeat(2,1fr); gap:1.8rem var(--s-md); } }
@media (max-width:520px){ .cs-meta{ grid-template-columns:1fr; } }

/* --- Hero visual ---------------------------------------------------------- */
.cs-keyvisual{ width:100%; margin-bottom:var(--s-xl); }
/* The page's own colour, not the darker card tone: a header image with
   transparency lets this through, and a band in a second shade reads as a
   box around the picture rather than as the page it sits on. */
.cs-keyvisual-media{ width:100%; height:clamp(28rem, 96vh, 60rem); overflow:hidden; background:var(--paper); }
/* display:contents so a <picture> — which is inline, and has no height of its
   own — does not become the box the image measures itself against. Without it
   an image given a mobile export collapses the moment it gains the wrapper. */
.cs-keyvisual-media picture, .cs-media picture{ display:contents; }
.cs-keyvisual-media img{ width:100%; height:100%; object-fit:cover; }
.cs-keyvisual-media svg{ width:100%; height:100%; }
@media (max-width:760px){ .cs-keyvisual-media{ height:clamp(20rem, 62vh, 30rem); } }

/* --- ProjectHighlights ---------------------------------------------------- */
.cs-summary{ padding:0 0 var(--s-xl); }
.cs-summary-inner{ max-width:var(--maxw); margin:0 auto; padding:0 var(--gutter); }
.cs-highlights{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0; border-top:1px solid var(--line-strong); }
.cs-highlight{ padding:var(--s-md) var(--s-md) var(--s-lg) 0; border-right:1px solid var(--line); }
.cs-highlight:last-child{ border-right:none; padding-right:0; }
.cs-highlight:not(:first-child){ padding-left:var(--s-md); }
.cs-highlight-key{ font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase; letter-spacing:0.14em; color:var(--ink-soft); margin-bottom:1.4rem; }
/* The same measure every other paragraph on the site obeys, rather than a
   number of its own. 38ch never bit in the three-column layout — the column is
   already narrower than that — so it only ever applied once the columns stacked,
   where it pinned the text at 425px inside a row 826px wide and left the rule
   above it running on into empty paper. --measure is 100% below 640px, so on a
   phone the text now fills the row, and between there and the three-column
   layout it wraps at the page's usual 68ch instead of a width unique to here. */
.cs-highlight-body{ font-size:clamp(1.05rem,1.15vw,1.25rem); line-height:1.55; max-width:var(--measure); }
.cs-highlight-body p{ margin:0 0 0.8em; }
.cs-highlight-body p:last-child{ margin-bottom:0; }
.cs-highlight-body a{ border-bottom:1px solid var(--signal); }
.cs-highlight-body strong, .cs-highlight-body b{ font-weight:600; }
@media (max-width:900px){
  .cs-highlights{ grid-template-columns:1fr; }
  .cs-highlight{ border-right:none; border-bottom:1px solid var(--line); padding:var(--s-md) 0; }
  .cs-highlight:not(:first-child){ padding-left:0; }
  .cs-highlight:last-child{ border-bottom:none; }
}

/* --- CaseNavigation ------------------------------------------------------- */
.cs-chapnav{ position:fixed; left:clamp(0.6rem,1.2vw,1.5rem); top:50%; transform:translateY(-50%); z-index:60; display:flex; flex-direction:column; gap:0.1rem; opacity:0; pointer-events:none; transition:opacity .5s var(--ease); }
.cs-chapnav.is-on{ opacity:1; pointer-events:auto; }
.cs-chapnav-rule{ position:absolute; left:0; top:0; bottom:0; width:1px; background:var(--line); }
.cs-chapnav-link{ display:flex; align-items:center; gap:0.7rem; padding:0.42rem 0 0.42rem 1rem; font-family:var(--mono); font-size:0.68rem; letter-spacing:0.08em; text-transform:uppercase; color:var(--ink-soft); opacity:.5; transition:opacity .35s var(--ease), color .35s var(--ease); position:relative; }
.cs-chapnav-link::before{ content:''; position:absolute; left:0; top:50%; width:1px; height:0; background:var(--ink); transform:translateY(-50%); transition:height .4s var(--ease); }
.cs-chapnav-link:hover{ opacity:1; }
.cs-chapnav-link.is-active{ opacity:1; color:var(--ink); }
.cs-chapnav-link.is-active::before{ height:100%; }
/* Labels stay collapsed by default so the rail never sits over the text
   column — the active chapter is signalled by the tick and contrast alone. */
.cs-chapnav-label{ max-width:0; overflow:hidden; white-space:nowrap; opacity:0; transition:max-width .45s var(--ease), opacity .35s var(--ease); }
/* The cap is what the label wipes open to, so it has to clear the longest one.
   16ch did not: ch counts glyph widths and this text also carries 0.08em of
   letter-spacing per character, so a fifteen-character label needs about 17ch
   and was cut mid-word. 24ch leaves room for twenty characters with their
   tracking, and stays snug enough that the wipe still reads as a wipe. */
.cs-chapnav:hover .cs-chapnav-label{ max-width:24ch; opacity:1; }
.cs-chapnav:hover{ background:var(--paper); box-shadow:0 0 0 1rem var(--paper); }
@media (max-width:1240px){ .cs-chapnav{ display:none; } }

/* --- chapters: shared ----------------------------------------------------- */
.cs-chapters{ position:relative; }
.chapter{ position:relative; }
.chapter__inner{ max-width:var(--maxw); margin:0 auto; padding:0 var(--gutter); width:100%; }
.chapter__inner--wide{ padding-top:var(--s-chapter); padding-bottom:var(--s-chapter); }

/* Height profiles — content-aware, never a uniform stack.
   --panel-h is how tall the chapter reads; --stick is the extra scroll runway
   that lets it hold in place while the next chapter approaches. */
.h-compact   { --panel-h: clamp(26rem, 66vh, 42rem);  --stick: 28vh; }
.h-standard  { --panel-h: clamp(34rem, 90vh, 56rem);  --stick: 40vh; }
.h-feature   { --panel-h: clamp(38rem, 100vh, 62rem); --stick: 0; }
.h-statement { --panel-h: clamp(30rem, 84vh, 50rem);  --stick: 44vh; }
.h-natural   { --panel-h: auto; --stick: 0; }

/* Panelled chapters stick briefly, then give way as the next chapter rides
   up over them. JS drives --exit (0 → 1); geometry does the work.
   The runway is what buys the pin: the panel is sticky, so the section must
   be taller than it for there to be any hold. Sizing the runway rather than
   the section keeps this correct even when content outgrows --panel-h. */
/* A panelled chapter must own the screen for at least one viewport before the
   next one starts arriving — otherwise a chapter shorter than the viewport is
   already being covered (and so already fading) while it is still being read.
   The runway guarantees travel when content is tall; this floor guarantees
   solitude when content is short. */
/* The reserved height follows the layout's own panel height rather than always
   a full viewport. Pinning still works — the hold is the difference between
   this and the panel, which is exactly what --stick is for — but a compact
   chapter no longer reserves a 900px screen for 600px of content and leaves the
   difference as a gap before the next chapter. */
.chapter[data-panel]{ z-index:1; min-height:calc(var(--panel-h) + var(--stick)); }
.chapter__runway{ height:var(--stick); pointer-events:none; }

/* A chapter with too little in it to fill the screen does not get to hold it.
   The pin reserves a viewport plus a runway whether or not there is anything
   to put in them, which left a chapter carrying one short paragraph sitting in
   about a thousand pixels of empty page. The script measures actual content and
   marks these; they then flow like any other section, with normal chapter
   spacing rather than a full screen of air. */
.chapter[data-panel].is-short .chapter__panel{
  position:relative; min-height:0;
  padding:var(--s-lg) 0;
  transform:none; opacity:1;
}
/* A panel that is not pinning has no hold to buy, so its runway is just empty
   page — and the reserved min-height above it is space nothing will occupy.
   The script marks both cases: too short to earn the screen, or too tall to be
   sticky at all. */
.chapter[data-panel].is-unpinned{ min-height:0; }
.chapter[data-panel].is-unpinned .chapter__runway{ display:none; }
.chapter[data-panel].is-unpinned .chapter__panel{ transform:none; opacity:1; }
/* Set by script when a panel outgrows the viewport — it then scrolls normally
   so none of its content is unreachable, keeping only the exit transition. */
.chapter__panel.is-tall{ position:relative; }
.chapter__panel{
  position:sticky; top:0; min-height:var(--panel-h);
  display:flex; align-items:center;
  padding:var(--s-lg) 0; background:var(--paper);
  transform-origin:50% 0;
  transform: scale(calc(1 - (0.04 * var(--exit, 0)))) translate3d(0, calc(-2.5vh * var(--exit, 0)), 0);
  opacity: calc(1 - (0.32 * var(--exit, 0)));
  will-change:transform,opacity;
}
/* Non-panelled chapters scroll normally and sit above the panel they cover. */
.chapter:not([data-panel]){ z-index:2; background:var(--paper); }

.chapter__head{ margin-bottom:var(--s-md); }
.chapter__num{ display:block; font-family:var(--mono); font-size:var(--t-label); letter-spacing:0.16em; color:var(--ink-soft); margin-bottom:1.2rem; }
.chapter__num--quiet{ margin-bottom:var(--s-md); }
.chapter__title{ font-family:var(--serif); font-size:var(--t-title); font-weight:420; line-height:1.06; letter-spacing:-0.012em; max-width:18ch; text-wrap:balance; }
.chapter__intro{ margin-top:var(--s-sm); font-size:clamp(1.05rem,1.25vw,1.3rem); line-height:1.55; color:var(--ink-soft); max-width:60ch; }
.chapter__intro p{ margin:0 0 0.85em; }
.chapter__intro p:last-child{ margin-bottom:0; }
.chapter__intro a{ border-bottom:1px solid var(--signal); }
.chapter__intro strong, .chapter__intro b{ color:var(--ink); font-weight:600; }
/* Block stacks always fill the grid column they sit in. The reading measure is
   applied per block (see .cs-prose) rather than to the container, so prose
   stays readable while media, data and two-column blocks use the full column —
   instead of the container being widened layout by layout, which is what left
   .chapter--metrics prose running the full 1400px shell. */
.chapter__blocks{ display:flex; flex-direction:column; gap:var(--s-md); width:100%; min-width:0; }

/* --- the shared column grid ----------------------------------------------
   Every two-part chapter resolves to the same 12 columns: the label/title rail
   on columns 1-4 and the content stream on columns 6-12. Previously "context"
   used 1/5 + 7/6 and "sticky-split" used 1/4 + 6/7, so the two layouts started
   their content on different columns (x=824 vs x=703 at 1600px) and nothing
   lined up down the page. --lead-span / --stream-start are the single source
   of truth; change them here and both layouts follow. */
:root{ --lead-span:4; --stream-start:6; --stream-span:7; }

.chapter__grid{ max-width:var(--maxw); margin:0 auto; padding:0 var(--gutter); }
.chapter__grid--context,
.chapter__grid--split{
  display:grid; grid-template-columns:repeat(12,minmax(0,1fr)); gap:var(--s-md);
  padding-top:var(--s-chapter); padding-bottom:var(--s-chapter);
  /* No rule above the chapter. These two layouts were the only ones drawing
     one, so a divider appeared before a context or sticky-split chapter and
     nowhere else — it read as a stray line rather than as structure. Chapters
     are separated by their spacing, consistently, like every other layout. */
}
.chapter__lead,
.chapter__aside{ grid-column:1 / span var(--lead-span); min-width:0; }
.chapter__column,
.chapter__stream{
  grid-column:var(--stream-start) / span var(--stream-span);
  display:flex; flex-direction:column; gap:var(--s-md); min-width:0;
}
.chapter__title--lead{ max-width:12ch; }
.chapter__aside-inner{ position:sticky; top:calc(var(--header-h) + var(--s-md)); }
/* Every chapter title is --t-title, including this one. It sits in the narrow
   sticky rail rather than across the full shell, and it used to be shrunk to
   suit — but that made one chapter in a run visibly smaller than its
   neighbours, which read as a mistake rather than as hierarchy. The context
   layout already lets a title wrap inside a rail of the same width, so wrapping
   here is consistent; only the measure is constrained. */
.chapter__title--aside{ max-width:14ch; }
/* The stream used to take --s-lg on the reasoning that it carries whole figures
   rather than paragraphs. That made it exactly twice the spacing of every other
   chapter, and since most pairs in a stream are prose-then-figure rather than
   figure-then-figure, the usual case got the treatment meant for the rare one —
   a paragraph and the picture it introduces sat nearly a hundred pixels apart.
   One rhythm across the page instead. */
.chapter__stream{ gap:var(--s-md); }

@media (max-width:960px){
  .chapter__grid--context,
  .chapter__grid--split{ grid-template-columns:1fr; gap:var(--s-md); }
  .chapter__lead, .chapter__aside,
  .chapter__column, .chapter__stream{ grid-column:auto; }
  .chapter__aside-inner{ position:static; margin-bottom:var(--s-md); }
  /* The rail is gone below this width, so the title has the full column and can
     use the same measure as every other chapter title. */
  .chapter__title--aside{ max-width:18ch; }
}

/* --- chapter layout: statement / accent ----------------------------------- */
.chapter__inner--statement{ max-width:var(--maxw); }
/* An accent chapter gives its headline insight room to breathe below the prose. */
.chapter--accent .chapter__blocks{ gap:var(--s-lg); }
.chapter--accent .cs-block--statement,
.chapter--accent .cs-block--quote{ padding-top:var(--s-sm); border-top:1px solid var(--line); }

/* --- blocks --------------------------------------------------------------- */
/* Text carries its own measure now that containers span the full column, so a
   block heading or paragraph keeps a readable line length in every layout. */
.cs-block-heading{ font-family:var(--serif); font-size:clamp(1.15rem,1.4vw,1.45rem); font-weight:520; letter-spacing:-0.005em; margin-bottom:0.9rem; max-width:var(--measure); }
.cs-prose{ max-width:var(--measure); }
.cs-prose p{ margin:0 0 1.15em; line-height:1.65; color:var(--ink-soft); }
.cs-prose p:last-child{ margin-bottom:0; }
/* Right-aligned prose stacks like any other text block — it is only pushed to
   the end of the column. Without an explicit direction the flex default (row)
   put the optional heading *beside* its own body text instead of above it. */
.cs-block--text--right{ display:flex; flex-direction:column; align-items:flex-end; }
.cs-block--text--right .cs-block-heading{ text-align:right; }
/* Internal splits share the grid's own gutter (--s-md) so a two-column text
   block and a text+media block line up with each other and with the page
   grid, instead of using two different gutters (--s-lg vs --s-md). */
.cs-block--two-col{ display:grid; grid-template-columns:1fr 1fr; gap:var(--s-md); }
/* Every block can carry an optional heading, and render_block puts it inside
   the block's own root element. Where that root is a grid, the heading would
   otherwise become a grid item and take the first cell — which is exactly what
   used to push a two-column block's left column into the right and its right
   column onto a second row. Spanning every track keeps it a heading. */
.cs-block--two-col > .cs-block-heading,
.cs-block--gallery > .cs-block-heading,
.cs-block--columns > .cs-block-heading,
.cs-block--before-after > .cs-block-heading{ grid-column:1 / -1; }

/* Text + media is the exception: its heading belongs to the text, not to the
   whole block. Spanning both columns made the title run to the far edge of the
   image while the prose it introduced stopped at the halfway point, and put a
   full grid row of space between the two. Placing it in the text column gives
   it the same measure as the paragraph below and closes that gap. The media
   spans both rows so it still starts at the top of the block. */
/* Row gap zeroed: the only row boundary is between a heading and the paragraph
   it introduces, which wants the heading's own margin, not the wide gutter that
   separates the text column from the image. */
.cs-block--split{ grid-template-rows:auto 1fr; column-gap:var(--s-md); row-gap:0; }
.cs-block--split > .cs-block-heading{ grid-column:1; grid-row:1; margin-bottom:0.7rem; }
.cs-block--split > .cs-prose{ grid-column:1; grid-row:2; }
.cs-block--split > .cs-split-figure{ grid-column:2; grid-row:1 / -1; }
/* A figure's caption sits under its media, so a heading must stay above it. */
.cs-block > .cs-block-heading{ order:-1; }
.cs-two-col{ display:contents; }
@media (max-width:760px){
  .cs-block--two-col{ grid-template-columns:1fr; gap:var(--s-md); }
}
.cs-prose a{ color:var(--ink); border-bottom:1px solid var(--signal); transition:color .25s ease; }
.cs-prose a:hover{ color:var(--signal); }
.cs-prose strong, .cs-prose b{ color:var(--ink); font-weight:600; }

.cs-media{ overflow:hidden; background:var(--paper-alt); aspect-ratio:16/10; }
.cs-media img{ width:100%; height:100%; object-fit:cover; }
.cs-media svg{ width:100%; height:100%; }
/* <picture> only appears when a block carries a separate mobile export. It is
   inline by default, which would put the image on a text baseline and leave a
   few pixels of the plate showing under it. */
.cs-media picture{ display:block; }
.cs-media--free{ aspect-ratio:auto; background:none; }
.cs-media--free img{ height:auto; object-fit:contain; }
.cs-caption{ font-family:var(--mono); font-size:var(--t-caption); line-height:1.5; color:var(--ink-soft); margin-top:1rem; max-width:52ch; }

/* --- code ----------------------------------------------------------------
   An editor panel: title bar, gutter, text. Dark, because that is what makes
   it read as code rather than as a quotation — --ink and the light values the
   footer already uses on it, so no new colour enters the system.

   No syntax colouring. It would need a parser per language and a palette this
   site does not have, and a half-hearted one reads worse than none.

   The gutter is sticky against the left edge so the numbers stay in place
   while a long line scrolls under them, and is unselectable so copying the
   block yields the source alone. */
.cs-code{
  background:var(--ink); color:#D6D3C7; border:1px solid var(--ink);
  overflow:hidden;
}
.cs-code-bar{
  display:flex; align-items:baseline; gap:0.9rem;
  padding:0.7rem 1rem; border-bottom:1px solid #33322A;
  font-family:var(--mono); font-size:var(--t-caption); line-height:1.4;
}
.cs-code-name{ color:#D6D3C7; }
.cs-code-lang{
  margin-left:auto; color:#7A7768;
  text-transform:uppercase; letter-spacing:0.1em; font-size:0.72em;
}
.cs-code-scroll{
  display:flex; align-items:flex-start;
  overflow:auto; max-height:26rem;
  overscroll-behavior-x:contain;   /* sideways scrolling stays in the panel */
}
.cs-code-scroll:focus-visible{ outline:2px solid var(--signal); outline-offset:-2px; }
.cs-code-gutter, .cs-code-body{
  margin:0; font-family:var(--mono); font-size:0.82rem; line-height:1.65;
  padding:1rem 0; white-space:pre;
}
.cs-code-gutter{
  position:sticky; left:0; z-index:1;
  background:var(--ink); color:#54524A;
  padding-inline:1rem 0.85rem; text-align:right;
  user-select:none; -webkit-user-select:none;
  border-right:1px solid #33322A;
}
.cs-code-body{ padding-inline:1.1rem 1.6rem; flex:1 0 auto; }
.cs-code-body code{ font:inherit; color:inherit; }

/* Four roles. #C9F0A0 is the green the admin already uses for code on ink;
   #9DB4FF stands in for --signal, which measures 2.4:1 here and cannot carry
   text; #8B8778 lifts the muted grey to 5.1:1 so a comment stays readable
   rather than merely present. */
.cs-code .tok-c{ color:#8B8778; font-style:italic; }
.cs-code .tok-s{ color:#C9F0A0; }
.cs-code .tok-k{ color:#9DB4FF; }
.cs-code .tok-h{ color:var(--paper); font-weight:600; }

/* --- embedded page -------------------------------------------------------
   A framed replay of another page of this site. pointer-events:none because
   the frame illustrates; it is not a second site to click around in, and a
   reader who started scrolling inside it would lose the page they were on. */
.cs-embed{
  position:relative; aspect-ratio:var(--embed-ratio, 16/10);
  border:1px solid var(--line); background:var(--paper); overflow:hidden;
}
.cs-embed iframe{
  position:absolute; inset:0; width:100%; height:100%;
  border:0; display:block; pointer-events:none;
}
/* Holds the closing frame. Appears instantly — it is standing in for a blue
   that is already on screen, so a fade in would read as a flash — and fades
   out over the first moments of the next run. */
.cs-embed-hold{
  position:absolute; inset:0; background:var(--signal);
  opacity:0; pointer-events:none; transition:opacity .35s var(--ease);
}
.cs-embed-hold.is-on{ opacity:1; transition:none; }

/* --- text beside the phone gallery ---------------------------------------
   Everything structural comes from .cs-block--split. The gallery draws its own
   frames and needs no plate behind them, and it sets its own height, so the
   figure slot here only has to get out of the way. */
.cs-block--split-phones > .cs-split-figure{ background:none; aspect-ratio:auto; }
.cs-block--split-phones .smg-mount{ width:100%; }
/* The frame never played — refused by the browser, or a page that will not
   run the animation. An empty rectangle says nothing, so the block removes
   itself and takes its caption with it. */
.cs-block--embed.is-unavailable{ display:none; }

@media (max-width:640px){
  .cs-code-gutter, .cs-code-body{ font-size:0.76rem; }
  .cs-code-scroll{ max-height:20rem; }
}

/* Full-bleed media escapes the reading column entirely — but only where that
   is actually possible. `margin-left:50%` is measured against the *container*,
   so the escape lands correctly only when the container is itself centred on
   the viewport. That holds for .chapter__blocks and the statement inner (both
   sit inside the centred .chapter__inner) and not for the context column or
   the split stream, where a full-bleed block would shoot past the right edge
   exactly the way contained figures used to. Filling the column is the correct
   fallback there, so the bleed is opted into rather than assumed. */
.cs-block--bleed{ width:100%; }
.chapter__blocks > .cs-block--bleed,
.chapter__inner--statement > .cs-block--bleed{
  width:100vw; margin-left:50%; transform:translateX(-50%);
}
.cs-media--bleed{ aspect-ratio:21/9; }
.chapter__blocks > .cs-block--bleed .cs-caption,
.chapter__inner--statement > .cs-block--bleed .cs-caption{
  max-width:var(--maxw); margin-left:auto; margin-right:auto; padding:0 var(--gutter);
}
@media (max-width:760px){ .cs-media--bleed{ aspect-ratio:4/3; } }

/* --- text + image carousel -----------------------------------------------
   Only ever applied when a block has more than one image; a single image keeps
   the markup and the rendering it always had.

   The carousel deliberately changes nothing outside the media box: the box
   keeps its own size, so the section's height, the text beside it and the
   spacing around it are all untouched as slides swap. */
.cs-carousel{ position:relative; }
.cs-carousel:focus{ outline:none; }
.cs-carousel:focus-visible{ outline:2px solid var(--signal); outline-offset:3px; }
.cs-carousel .cs-media{ position:relative; }

/* Every slide occupies the same single grid cell, so the box is as tall as the
   tallest slide from the start and never resizes — no reflow mid-rotation, and
   nothing shifts while a later image is still loading.

   The stack is taken out of flow so the images cannot dictate the media box's
   height: the box keeps precisely the size it has with one image, which is what
   holds the section height and the text beside it steady. */
.cs-slides{ position:absolute; inset:0; }
.cs-slide{
  position:absolute; inset:0; min-width:0;
  opacity:0; transition:opacity .5s var(--ease);
  pointer-events:none;
}
.cs-slide.is-active{ opacity:1; pointer-events:auto; }
/* contain, not cover: a set of images with differing proportions all stay
   whole inside one shared frame rather than each being cropped differently. */
.cs-slide img, .cs-slide svg{
  display:block; width:100%; height:100%; object-fit:contain;
}

/* Free media has no fixed proportion, so there is no box to fill. The slides
   stay in flow, stacked in one grid cell, and the tallest of them sets a height
   that then holds for all of them. */
.cs-media--free .cs-slides{ position:relative; display:grid; }
.cs-media--free .cs-slide{ position:relative; grid-area:1 / 1; }
.cs-media--free .cs-slide img, .cs-media--free .cs-slide svg{ height:auto; }

.cs-dots{
  display:flex; justify-content:center; gap:0.15rem;
  margin-top:0.75rem;
}
/* The button is the hit area and is comfortably larger than the mark it draws,
   which is the small dot inside it. */
.cs-dot{
  width:22px; height:22px; padding:0; border:0; background:none;
  display:flex; align-items:center; justify-content:center;
  cursor:pointer;
}
.cs-dot span{
  display:block; width:6px; height:6px; border-radius:50%;
  background:var(--ink-soft); opacity:0.35;
  transition:opacity .3s var(--ease), background-color .3s var(--ease);
}
.cs-dot:hover span{ opacity:0.6; }
.cs-dot.is-active span{ background:var(--ink); opacity:1; }
.cs-dot:focus-visible{ outline:2px solid var(--signal); outline-offset:1px; border-radius:50%; }

@media (prefers-reduced-motion: reduce){
  /* Autoplay is switched off in the script; this removes the cross-fade so a
     manual choice simply appears. */
  .cs-slide{ transition:none; }
}

/* Contained media fills the column it is placed in.

   This used to be `width:min(1180px,90vw); margin-left:50%; translateX(-50%)`,
   which centres a block on its *container's* midpoint — correct only when that
   container is itself centred on the viewport. Inside the context column
   (which starts at x=824) the same rule put a figure at x=572 running to
   x=1752, i.e. 152px past the right edge of a 1600px viewport, hidden only by
   `overflow-x:hidden` on body. It also produced a 1180px width unrelated to
   the grid, and every new wrapper needed its own `.chapter__stream …` reset.
   Column-relative widths line up with the grid by construction and need no
   per-ancestor exceptions. */
/* An artefact asked to stay at reading width rather than take the measure. */
.cs-block--artefact-narrow > .cs-media{ max-width:44rem; margin-inline:auto; }
.cs-block--artefact-narrow > .cs-caption{ max-width:44rem; margin-inline:auto; }

.cs-block--figure, .cs-block--artefact, .cs-block--gallery, .cs-block--screens,
.cs-block--before-after, .cs-block--mosaic, .cs-block--phones,
.cs-block--columns, .cs-block--next, .cs-block--slider, .cs-block--stores,
.cs-block--process, .cs-block--code, .cs-block--embed{ width:100%; min-width:0; }

/* --- sideways scrolling: saying so ---------------------------------------
   Three things on a case study scroll horizontally on a phone — the phone
   carousel, the process track and a long line of code — and each hid its
   scrollbar to keep the page quiet. Quiet became silent: nothing said the row
   continued past the right edge, so it read as a row that simply ended there.

   A soft edge is the least intrusive way to say otherwise. It fades only the
   side that still has content behind it, so it doubles as a position
   indicator: fade on the right alone means the start, both means the middle,
   left alone means the end. State comes from the element's own scrollLeft —
   set by the interaction layer for the static blocks, by the component for the
   carousel — because a fade on a side with nothing behind it is a lie.

   mask-image rather than an overlaid gradient: an overlay would have to know
   the background behind it, and these blocks sit on --paper in one place and
   inside a tinted plate in another. -webkit- for Safari before 15.4. */
[data-xscroll], .smg--carousel .smg-track{ --x-fade:2rem; }
[data-xscroll="start"], .smg--edge-start .smg-track{
  -webkit-mask-image:linear-gradient(to right, #000 calc(100% - var(--x-fade)), transparent 100%);
          mask-image:linear-gradient(to right, #000 calc(100% - var(--x-fade)), transparent 100%);
}
[data-xscroll="middle"], .smg--edge-middle .smg-track{
  -webkit-mask-image:linear-gradient(to right, transparent 0, #000 var(--x-fade), #000 calc(100% - var(--x-fade)), transparent 100%);
          mask-image:linear-gradient(to right, transparent 0, #000 var(--x-fade), #000 calc(100% - var(--x-fade)), transparent 100%);
}
[data-xscroll="end"], .smg--edge-end .smg-track{
  -webkit-mask-image:linear-gradient(to right, transparent 0, #000 var(--x-fade));
          mask-image:linear-gradient(to right, transparent 0, #000 var(--x-fade));
}

/* --- project process -----------------------------------------------------
   One line, always. When the stages outrun the width the track scrolls
   sideways rather than wrapping, so the process is never read as two rows.

   Above the stages runs a single hairline with a progress bar over it, filled
   by the interaction layer as the reader scrolls through the block — the same
   idea as the Journey timeline on the About page, turned on its side. The bar
   only ever grows, so once it has reached the end it stays there. */
.cs-process-scroll{
  overflow-x:auto; overscroll-behavior-x:contain;
  scrollbar-width:none; -ms-overflow-style:none;
  /* Setting overflow on one axis makes the other axis clip too, so the half of
     each dot that sits above the line was being cut off. The padding gives that
     half somewhere to be. */
  padding-top:6px;
}
.cs-process-scroll::-webkit-scrollbar{ display:none; }
.cs-process-scroll:focus-visible{ outline:2px solid var(--signal); outline-offset:4px; }

/* Columns are laid along a single row. Each holds at least --stage-w, so a few
   stages share the width evenly and many overflow into the scroll instead of
   being squeezed to nothing. */
.cs-process{
  --stage-w: 230px;
  position:relative; display:grid;
  grid-auto-flow:column; grid-auto-columns:minmax(var(--stage-w), 1fr);
  min-width:100%; list-style:none; margin:0; padding:0;
  border-top:1px solid var(--line);
}
/* No transition: the width is rewritten every frame from the scroll position,
   so the bar already moves with the page. A transition on top of that made each
   frame chase the last one — the line lurched forward and then sat still
   instead of tracking the scroll. */
.cs-process-bar{
  position:absolute; top:-1px; left:0; width:0; height:2px;
  background:var(--signal); pointer-events:none; z-index:1;
}
.cs-stage{ position:relative; padding:1.15rem var(--s-md) 0 0; }
.cs-stage:last-child{ padding-right:0; }
/* A point where each stage begins, sitting on the line. It fills as the
   progress bar reaches it, so the track reads as a set of stops being passed
   rather than a bar sliding over nothing. */
.cs-stage::before{
  content:''; position:absolute; top:-5px; left:0; z-index:2;
  width:9px; height:9px; border-radius:50%;
  background:var(--paper); border:1px solid var(--line);
  transition:background-color .35s var(--ease), border-color .35s var(--ease);
}
.cs-stage.is-reached::before{ background:var(--signal); border-color:var(--signal); }
.cs-stage-meta{
  font-family:var(--mono); font-size:var(--t-label); letter-spacing:0.12em;
  text-transform:uppercase; color:var(--ink-soft); margin:0 0 0.8rem;
}
/* The separator is written into the markup rather than injected as generated
   content: a CSS escape here was read as two hex digits rather than four and
   rendered a null character. Plain text cannot be misread. */
.cs-stage-sep{ margin:0 0.5em; opacity:0.55; }
.cs-stage-title{
  font-family:var(--serif); font-size:clamp(1.05rem,1.25vw,1.3rem); font-weight:520;
  line-height:1.25; letter-spacing:-0.005em; margin:0 0 0.55rem;
}
.cs-stage-body{ font-size:0.95rem; line-height:1.6; color:var(--ink-soft); }
.cs-stage-body p{ margin:0 0 0.7em; }
.cs-stage-body p:last-child{ margin-bottom:0; }

@media (max-width:560px){
  /* Narrower stages so more than one is in view before the reader has to
     scroll, but still one line. */
  .cs-process{ --stage-w: 190px; }
}
@media (prefers-reduced-motion: reduce){
  .cs-stage::before{ transition:none; }
}

/* --- app store links -----------------------------------------------------
   Centred badges, one per store. The plate is --ink and the mark --paper, so
   the badges follow the page's own black-and-white rather than each store's
   colour artwork. */
.cs-stores{
  display:flex; flex-wrap:wrap; justify-content:center; align-items:center;
  gap:var(--s-sm);
}
.cs-store{
  display:inline-flex; border:0;
  transition:transform .3s var(--ease), opacity .3s var(--ease);
}
.cs-store:hover{ transform:translateY(-2px); opacity:0.86; }
.cs-store:focus-visible{ outline:2px solid var(--signal); outline-offset:4px; border-radius:8px; }
/* Height is the badge's fixed dimension; width follows the artwork. */
.cs-stores{ --badge-h:clamp(42px,3.6vw,50px); }
.cs-store img{ display:block; height:var(--badge-h); width:auto; }

/* Google's published PNG is 646x250 with their required clear space — 41px on
   every side — inside the file, so its plate is only 168 of those 250 pixels.
   At an equal CSS height it would render two thirds the size of Apple's badge,
   which is plate-only. Scaling by 250/168 makes the two plates match, and the
   negative margin gives back the width the built-in padding would otherwise add
   to the gap. Corrected here rather than by cropping the file, which would mean
   altering artwork Google asks not to be altered. */
.cs-store--google img{ height:calc(var(--badge-h) * 1.4881); }
.cs-store--google{ margin:calc(var(--badge-h) * -0.244) calc(var(--badge-h) * -0.244); }
/* The block is centred as a whole, so its own title and caption centre with it
   rather than staying left against a centred row of badges.

   A column with one gap, rather than each part carrying its own margin. The
   sentence used to sit 14px above the badges on its own margin-bottom, which
   is tighter than any other pairing on the page; one gap of --s-md puts this
   block on the same rhythm as everything else and gives the caption below the
   badges the same spacing, instead of a third value.

   The section is one short sentence and two badges. Left at its natural
   height it hangs off the bottom of the chapter title with the rest of the
   band empty under it, so the block is given a floor and its contents are
   centred in it: the space above and below the pair now matches. */
.cs-block--stores{
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:var(--s-md); min-height:clamp(14rem, 24vh, 20rem); text-align:center;
}
.cs-block--stores > .cs-block-heading{ margin:0; }
.cs-block--stores .cs-caption{ margin:0; }
/* Centred text wants a shorter line than a left-aligned paragraph: the reader
   hunts for the start of each line rather than returning to a fixed edge. At
   the page measure this sentence ran 908px and broke into two long lines that
   crossed most of the band. */
.cs-block--stores > .cs-block-heading,
.cs-block--stores .cs-caption{ margin-left:auto; margin-right:auto; text-align:center; max-width:44ch; }

/* --- next case study -----------------------------------------------------
   Text on the left, one project card on the right, using the same two-column
   shape as text+media so this section lines up with the rest of the page
   rather than inventing a second set of proportions.

   The card spans both rows and the columns are top-aligned, so the heading and
   its paragraph start level with the top of the card instead of being centred
   against it. */
.cs-block--next{
  display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr);
  grid-template-rows:auto 1fr; column-gap:var(--s-md); row-gap:0;
  align-items:start;
}
.cs-block--next > .cs-block-heading{ grid-column:1; grid-row:1; margin-bottom:0.7rem; }
.cs-block--next > .cs-prose{ grid-column:1; grid-row:2; }
.cs-block--next > .cs-next-slot{ grid-column:2; grid-row:1 / -1; min-width:0; }

@media (max-width:860px){
  /* Stacked in source order — title, text, then the card — with the horizontal
     gutter becoming the vertical one, exactly as text+media does. */
  .cs-block--next{ grid-template-columns:1fr; grid-template-rows:none; row-gap:0; }
  .cs-block--next > .cs-block-heading,
  .cs-block--next > .cs-prose,
  .cs-block--next > .cs-next-slot{ grid-column:1; grid-row:auto; }
  .cs-block--next > .cs-next-slot{ margin-top:var(--s-md); }
}

/* The project card itself.

   Ported from the Work section of index.html so the card looks and behaves
   identically here — same square, same gradient scrim, same type, same 0.6s
   scale on hover. The two pages hold separate inline stylesheets, so these
   rules are a copy: if the homepage card changes, change it here too.

   Everything is scoped under .cs-next-card so nothing else on a case study
   page can pick these up, and so the homepage's own rules — including the
   project number — are left exactly as they are. */
.cs-next-card{
  position:relative; display:block; aspect-ratio:1/1; overflow:hidden;
  border:1px solid var(--line); background:var(--paper-alt);
}
.cs-next-card .project-visual{
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  padding:clamp(2rem,6vw,3.5rem);
  transition:transform .6s cubic-bezier(.2,.7,.2,1);
}
.cs-next-card .project-visual svg{ width:100%; height:100%; }
.cs-next-card .project-visual img{
  position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block;
}
.cs-next-card:hover .project-visual{ transform:scale(1.05); }
.cs-next-card::before{
  content:''; position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(to top, rgba(21,20,15,0.94) 0%, rgba(21,20,15,0.72) 45%, rgba(21,20,15,0.18) 80%);
}
.cs-next-card .project-overlay{
  position:absolute; left:0; right:0; bottom:0; z-index:2; padding:clamp(1.3rem,3vw,2rem);
}
.cs-next-card .project-meta-row{ display:flex; align-items:center; gap:0.7rem; margin-bottom:0.8rem; }
.cs-next-card .project-badge{
  display:none; font-family:var(--mono); font-size:0.65rem; text-transform:uppercase;
  letter-spacing:0.06em; color:var(--paper); background:var(--signal);
  border-radius:999px; padding:0.28rem 0.7rem;
}
.cs-next-card.project--soon .project-badge{ display:inline-flex; align-items:center; }
/* The reading label, in the same hairline-pill language as the tags below it
   — the homepage card sets the pattern and this is its scoped copy. */
.cs-next-card .project-read{
  display:inline-flex; align-items:center; font-family:var(--mono); font-size:0.65rem;
  text-transform:uppercase; letter-spacing:0.06em; color:#D6D3C7;
  border:1px solid rgba(250,249,245,0.3); border-radius:999px;
  padding:0.28rem 0.7rem; white-space:nowrap;
}
.cs-next-card .project-lock{
  display:inline-flex; align-items:center; font-family:var(--mono); font-size:0.65rem;
  text-transform:uppercase; letter-spacing:0.06em; color:#D6D3C7;
  border:1px solid rgba(250,249,245,0.3); border-radius:999px;
  padding:0.28rem 0.7rem; white-space:nowrap; background:rgba(250,249,245,0.08);
}
/* The large line is the project; the small line under it names the client. */
.cs-next-card .project-title{
  font-family:var(--serif); font-style:italic; font-weight:440;
  font-size:clamp(1.3rem,2.4vw,1.7rem); line-height:1.2; color:var(--paper);
  margin:0 0 0.4rem; max-width:26ch;
}
.cs-next-card .project-company{
  font-size:0.92rem; color:#D6D3C7; max-width:34ch; margin:0 0 1.1rem;
}
.cs-next-card .project-tags{ display:flex; flex-wrap:wrap; gap:0.5rem; }
.cs-next-card .project-tag{
  font-family:var(--mono); font-size:0.66rem; text-transform:uppercase; letter-spacing:0.03em;
  color:var(--paper); border:1px solid rgba(250,249,245,0.35); border-radius:999px;
  padding:0.32rem 0.75rem; background:rgba(250,249,245,0.08);
}
/* The link covers the whole card, so the entire square is the tap target. */
.cs-next-card .project-card-link{ position:absolute; inset:0; z-index:3; }
.cs-next-card.project--soon .project-card-link{ pointer-events:none; }
.cs-next-card.project--soon .project-visual{ filter:grayscale(1); opacity:0.55; }

/* Text and media start on the same line. Centring them vertically pushed the
   prose down whenever the image was the taller of the two, so the two halves
   of one block read as if they began at different points. */
/* column-gap only, never the `gap` shorthand: the block's placement rules above
   zero the row gap so a heading sits close to its own paragraph, and a
   shorthand here would silently reinstate it. */
.cs-block--split{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1.15fr); column-gap:var(--s-md); align-items:start; width:100%; min-width:0; }
@media (max-width:860px){
  /* Stacked: one column, source order. The spacing hierarchy is kept rather
     than handed to a uniform row gap — a heading still sits close to its own
     paragraph, and the gutter that separated text from image horizontally now
     separates them vertically. */
  /* minmax(0,1fr), never a bare 1fr — for the same reason the two-column rule
     above spells it out. A bare 1fr is minmax(auto,1fr), so the column cannot
     go below its content's min-content width, and the phone gallery's min-content
     is every frame laid side by side. At 390px that sized this one column to
     596px and pushed the whole page sideways; the carousel could not scroll
     because it had already been given room for all of it. */
  .cs-block--split{ grid-template-columns:minmax(0,1fr); grid-template-rows:none; row-gap:0; }
  .cs-block--split > .cs-block-heading,
  .cs-block--split > .cs-prose,
  .cs-block--split > .cs-split-figure{ grid-column:1; grid-row:auto; }
  .cs-block--split > .cs-split-figure{ margin-top:var(--s-md); }
}

/* --- three columns -------------------------------------------------------
   One row, one template. Each column carries any mix of title, image and text,
   and every part is optional — so a row of three headings, a row of three
   pictures and a row of picture-plus-caption are all this block with different
   parts filled in.

   Columns are equal tracks and top-aligned, so a column holding only a title
   lines up with the top of the picture beside it rather than floating in the
   middle of it. */
.cs-block--columns{ display:grid; gap:var(--s-md); align-items:start; }
.cs-columns--1{ grid-template-columns:minmax(0,1fr); }
.cs-columns--2{ grid-template-columns:repeat(2,minmax(0,1fr)); }
.cs-columns--3{ grid-template-columns:repeat(3,minmax(0,1fr)); }
.cs-columns--4{ grid-template-columns:repeat(4,minmax(0,1fr)); }
.cs-col{ display:flex; flex-direction:column; gap:0.85rem; min-width:0; }
/* The column is already narrow, so the reading measure would never bite —
   letting it fill the track is what keeps the three of them even. */
.cs-col .cs-prose{ max-width:none; }
.cs-col .cs-media{ width:100%; }
/* The row's own caption belongs to the whole block, not to a track. */
.cs-block--columns > .cs-caption{ grid-column:1 / -1; margin-top:0; }
/* Identifying marks, held to one height so three logos of different
   proportions still read as a row. Kept in ink-on-paper company by a slight
   knock-back, since a full-colour mark shouts next to the prose. */
.cs-col-logo{ height:1.6rem; margin-bottom:0.85rem; display:flex; align-items:center; }
.cs-col-logo img{ height:100%; width:auto; object-fit:contain; opacity:0.88; }

.cs-col-title{
  font-family:var(--serif); font-size:clamp(1.05rem,1.25vw,1.3rem); font-weight:520;
  line-height:1.25; letter-spacing:-0.005em; margin:0; text-wrap:balance;
}
@media (max-width:860px){
  /* Two up before stacking — three columns of body copy are unreadable on a
     tablet, and one long column is a lot of scrolling. */
  .cs-columns--3, .cs-columns--4{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media (max-width:560px){
  .cs-block--columns{ grid-template-columns:minmax(0,1fr) !important; gap:var(--s-md); }
}

.cs-block--gallery{ display:grid; gap:var(--s-sm); grid-template-columns:repeat(2,minmax(0,1fr)); }
.cs-block--gallery[data-n="1"]{ grid-template-columns:minmax(0,1fr); }
.cs-block--gallery[data-n="3"]{ grid-template-columns:repeat(3,minmax(0,1fr)); }
.cs-block--gallery[data-n="4"]{ grid-template-columns:repeat(4,minmax(0,1fr)); }
.cs-gallery-item{ min-width:0; }
/* Fewer across as the page narrows, in steps the content chooses rather than
   wherever a fixed minimum width happens to fall. A pair stays a pair well into
   tablet widths: dropping it to one full-width cell is what stretched a
   portrait sideways. */
@media (max-width:900px){
  .cs-block--gallery[data-n="4"]{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media (max-width:700px){
  .cs-block--gallery[data-n="3"]{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media (max-width:520px){
  .cs-block--gallery[data-n]{ grid-template-columns:minmax(0,1fr); }
}
/* A height set on the block wins over the 16/10 the cells otherwise keep. The
   aspect ratio has to be cleared, not just overridden: with both in play the
   ratio still sets the box and the height is ignored.

   Switched on by an attribute rather than by testing whether the property is
   set, so the rule says plainly when it applies. */
.cs-block--gallery[data-h] .cs-gallery-item > .cs-media{
  aspect-ratio:auto; height:var(--gallery-h);
}

/* video — responsive 16:9 frame for embeds and self-hosted files alike */
.cs-video{ position:relative; aspect-ratio:16/9; background:var(--ink); overflow:hidden; }
.cs-video iframe, .cs-video video{ position:absolute; inset:0; width:100%; height:100%; border:0; display:block; }

/* Media mosaic — several images read as one composed group.
   The container carries the aspect ratio and the cells fill it, so the tall
   image and the stacked ones always align to the same outer edges. */
.cs-mosaic{ display:grid; gap:clamp(0.5rem,0.9vw,1rem); }
.cs-mosaic-item{ min-width:0; min-height:0; }
.cs-mosaic-item .cs-media{ width:100%; height:100%; aspect-ratio:auto; }
.cs-mosaic--1{ grid-template-columns:1fr; aspect-ratio:16/9; }
.cs-mosaic--2{ grid-template-columns:repeat(2,minmax(0,1fr)); aspect-ratio:16/7.5; }
/* the reference shape: one tall frame, two stacked beside it */
.cs-mosaic--3{ grid-template-columns:1.04fr 1fr; grid-template-rows:repeat(2,minmax(0,1fr)); aspect-ratio:16/9.6; }
.cs-mosaic--3 .cs-mosaic-item:first-child{ grid-row:1 / span 2; }
.cs-mosaic--4{ grid-template-columns:repeat(2,minmax(0,1fr)); grid-template-rows:repeat(2,minmax(0,1fr)); aspect-ratio:16/12; }
.cs-mosaic--many{ grid-template-columns:repeat(auto-fit,minmax(min(240px,100%),1fr)); }
.cs-mosaic--many .cs-media{ aspect-ratio:4/3; height:auto; }
/* A height set on the block. The composed shapes take it as the height of the
   group; the flowing grid takes it per picture. In both cases the aspect ratio
   has to be cleared as well as the height set, or the ratio still decides the
   box and the height does nothing. */
.cs-block--mosaic[data-h] .cs-mosaic{ aspect-ratio:auto; height:var(--mosaic-h); }
.cs-block--mosaic[data-h] .cs-mosaic--many{ height:auto; }
.cs-block--mosaic[data-h] .cs-mosaic--many .cs-media{ aspect-ratio:auto; height:var(--mosaic-h); }
@media (max-width:760px){
  /* Stack on small screens — a two-up mosaic makes every image too small to read. */
  .cs-mosaic{ grid-template-columns:1fr !important; grid-template-rows:none !important; aspect-ratio:auto !important; }
  .cs-mosaic .cs-mosaic-item{ grid-row:auto !important; }
  .cs-mosaic .cs-media{ aspect-ratio:4/3; height:auto; }
  /* Stacked, so the height belongs to each picture rather than to the group. */
  .cs-block--mosaic[data-h] .cs-mosaic{ height:auto; }
  .cs-block--mosaic[data-h] .cs-mosaic .cs-media{ aspect-ratio:auto; height:var(--mosaic-h); }
}

.cs-block--before-after{ display:grid; grid-template-columns:1fr 1fr; gap:var(--s-sm); }
.cs-ba-label{ display:block; font-family:var(--mono); font-size:var(--t-caption); text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-soft); margin-bottom:0.8rem; }
@media (max-width:640px){ .cs-block--before-after{ grid-template-columns:1fr; } }

/* --- ScrollableMobileGallery ----------------------------------------------
   Phone frames whose screenshots scroll independently. Mounted by React from
   assets/scrollable-mobile-gallery.js; the .smg-fallback below is what a reader
   without JavaScript gets, and the component clears it on mount.

   Deliberate omission: no `overscroll-behavior` on .smg-viewport. The default
   chains, so reaching the top or bottom of a screen hands scrolling back to the
   page. `contain` would trap the reader inside the phone. */
/* The gallery is a figure in a case study, not a full-height app preview: it
   has to be readable without scrolling the page to see the bottom of a phone.
   Height is therefore capped against the viewport rather than set outright,
   and the padding is a small share of it rather than the chapter's own --s-xl
   rhythm, which was most of the excess. */
/* The element React mounts into. Kept separate from .cs-block--phones so the
   block's heading and caption survive mounting — see block_scrollableMobileGallery. */
.smg-mount{ width:100%; min-width:0; }
.smg{
  width:100%; min-width:0;
  padding-block:clamp(0.75rem, 3vh, 2rem);
  overflow:hidden;
}
.smg-track{ display:flex; gap:var(--smg-gap,2rem); align-items:center; }
.smg--row .smg-track{ justify-content:flex-start; }
.smg--grid .smg-track{ flex-wrap:wrap; }

.smg-frame{ margin:0; flex:0 0 auto; max-width:100%; }
/* The device is the bezel and carries the outer radius. It holds exactly one
   child and nothing inside it is rounded on its own — which is what stops the
   frame's own edge appearing to cut through the header. */
.smg-device{
  padding:var(--smg-bezel,5px);
  border-radius:var(--smg-radius-outer,30px);
  background:var(--paper-alt);
  box-shadow:0 0 0 1px var(--line);
}

/* The screen is the single clipping surface: the only element here with both
   overflow:hidden and a radius. Header, content and navigation are therefore
   clipped by one shared curve and read as one continuous interface rather than
   three rounded cards stacked in a phone shape. No padding — the bars have to
   reach both inner edges. */
.smg-screen{
  position:relative;
  display:flex; flex-direction:column;
  overflow:hidden;
  border-radius:var(--smg-radius-inner,25px);
  background:var(--paper-alt);
  /* Width and height are both set from one scale factor by the component, so
     the device keeps its authored proportion — roughly a handset — instead of
     being squashed to fit the window. Capping the height here as well would
     re-introduce exactly that squash. */
}

/* Region 2 of 3. flex:1 with min-height:0 resolves to exactly
   screen height − header height − navigation height, so the content sits in
   genuinely reserved space and never runs under a fixed bar. Nothing is
   overlaid to hide it. */
.smg-viewport{
  flex:1 1 auto; min-height:0; position:relative; z-index:1;
  overflow-y:auto; overflow-x:hidden;
  scrollbar-width:none; -webkit-overflow-scrolling:touch;
}
.smg-viewport::-webkit-scrollbar{ display:none; }
.smg-viewport:focus-visible{ outline:2px solid var(--signal); outline-offset:-2px; }
/* The screenshot keeps its natural proportion — interface designs differ in
   length, so height is never forced and it is never cropped. This is also why
   the block does not reuse .cs-media, whose aspect-ratio + object-fit:cover
   would crop them. */
.smg-shot{ display:block; width:100%; height:auto; object-fit:contain; }
/* A live page in place of the screenshot. It is laid out at the frame's authored
   width and scaled with the rest of the device, so it renders the desktop layout
   it was designed at instead of reflowing into a narrow box. The page scrolls
   itself — its own fixed header stays fixed — so the viewport around it does not. */
.smg-viewport.is-live{ overflow:hidden; }
.smg-live{
  position:absolute; top:0; left:0; display:block; border:0; background:#fff;
  width:calc(100% / var(--smg-live-scale, 1)); height:calc(100% / var(--smg-live-scale, 1));
  transform:scale(var(--smg-live-scale, 1)); transform-origin:0 0;
}
.smg-missing{
  padding:var(--s-md) 1.25rem; font-family:var(--mono); font-size:var(--t-caption);
  color:var(--ink-soft); text-align:center;
}

/* Regions 1 and 3. Fixed by being flex siblings of the scroller rather than
   children of it, in a full-width zero-margin box whose height the component
   sets from the image's own aspect ratio.

   Deliberately no border and no shadow: these bars are captures of real app
   chrome and already carry their own hairline rule, so adding one doubles it.
   `.has-dividers` opts one in for an asset that lacks its own. */
/* Both bars position their image absolutely: the component measures where the
   solid bar sits inside the asset and offsets the image so that bar — not the
   canvas it was exported on — is what lines up with the screen. Design tools
   emit these with wildly different padding, shadows and floating buttons, and
   correcting it here is what stops every new upload needing to be cropped by
   hand to match the others. */
/* The screen is a column of bars around a scrolling middle. With a sidebar it
   becomes a row first — rail, then that same column beside it — so the rail
   stays put while the content next to it moves, which is how a desktop tool
   actually behaves. Everything else about the frame is unchanged, and a screen
   with no sidebar takes the same path it always did. */
.smg-main{ flex:1 1 auto; min-width:0; min-height:0; display:flex; flex-direction:column; }
.smg-screen.has-side{ flex-direction:row; }
.smg-screen.has-side--right{ flex-direction:row-reverse; }
.smg-side{ flex:0 0 auto; position:relative; z-index:2; height:100%; }
/* Placed by its own proportions: exported at the screen's height, its width
   follows from the picture rather than from a number someone has to keep in
   step with the artwork. */
.smg-side img{ display:block; height:100%; width:auto; max-width:none; }

.smg-bar{ flex:0 0 auto; position:relative; z-index:2; }
/* max-width:none matters: the global img rule caps every image at its
   container, which would clamp a bar that has to be drawn *wider* than the
   screen so its inner bar lines up — the padding is supposed to fall outside. */
.smg-bar img{ display:block; position:absolute; left:0; width:100%; max-width:none; height:auto; object-fit:contain; }
.smg-header img{ top:0; }
.smg-nav img{ bottom:0; }

/* Clearance under the corner arc so a title or icon at the very top of the
   header is never cut by the curve. Painted in the chrome colour so it reads
   as the app's own status area rather than as a gap — the only place a
   background is drawn, because everywhere else the asset supplies its own. */
.smg-header{
  background:var(--smg-chrome,#fff);
  padding-top:var(--smg-safe-top,14px);
  overflow:hidden;
}

/* The navigation reserves only its solid bar. Anything above that — typically
   a floating action button on a transparent field — overhangs upward and
   floats over the interface, exactly as it does in the app. No background is
   painted behind it, which is what previously turned that transparency into a
   white strip above the navigation. pointer-events stay off so the overhang
   never intercepts a scroll aimed at the content underneath. */
.smg-nav{ overflow:visible; pointer-events:none; }

.smg-screen.has-dividers .smg-header{ box-shadow:inset 0 -1px 0 var(--line); }
.smg-screen.has-dividers .smg-nav{ box-shadow:inset 0 1px 0 var(--line); }

/* Sits just above the navigation bar rather than under it. The bar's height is
   whatever the author's image happens to be, so the component measures it and
   publishes --smg-nav-h; the fallback of 0px covers screens with no nav. */
.smg-hint{
  position:absolute; left:50%; bottom:calc(var(--smg-nav-h, 0px) + 1rem); transform:translateX(-50%);
  padding:0.4rem 0.9rem; border-radius:999px;
  background:rgba(21,20,15,0.82); color:var(--paper);
  font-family:var(--mono); font-size:0.68rem; letter-spacing:0.08em; text-transform:uppercase;
  white-space:nowrap; pointer-events:none;
  opacity:1; transition:opacity .45s var(--ease);
}
.smg-hint.is-gone{ opacity:0; }

.smg-caption{
  margin-top:0.9rem; font-family:var(--mono); font-size:var(--t-caption);
  line-height:1.5; color:var(--ink-soft);
}

/* Pointer affordance, matching the disc the homepage shows on its project
   cards. A screenshot looks like a flat picture, so this is what tells the
   reader the phone can be scrolled before they try it. The component adds
   .has-cursor only on a fine pointer, so touch devices keep their own cursor
   and nothing is hidden from them. */
.smg-cursor{
  position:fixed; top:0; left:0; z-index:200;
  width:62px; height:62px; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  background:var(--ink); color:var(--paper);
  font-family:var(--mono); font-size:0.6rem; letter-spacing:0.08em; text-transform:uppercase;
  pointer-events:none; opacity:0;
  transform:translate(-50%,-50%) scale(0.5);
  transition:opacity .25s ease;
  will-change:transform, opacity;
}
.smg-cursor.is-active{ opacity:1; }
.smg.has-cursor .smg-viewport{ cursor:none; }

/* --- a plain band -------------------------------------------------------
   A stretch of content that is not a chapter: no number, no title, not in the
   menu. It keeps the page's own width and gutter so it lines up with the
   chapters around it, and takes the chapter rhythm above and below so it does
   not read as something that fell out of the flow — the only thing it drops is
   the heading, which is the whole point of it. */
/* A band sits close to what follows it, and that takes three rules because the
   space below a chapter is built in three places: the band's own padding, the
   padding its inner container carries like any chapter's, and the top padding
   of whatever comes next. Left alone they add up to about 250px — a full
   chapter's worth of silence after something that is not a chapter.

   Above the band nothing changes: the gap up to the previous chapter is that
   chapter's own, and it should stay. */
.chapter--plain{ padding-block:0; }
.chapter--plain > .chapter__inner{ padding-block:0; }
.chapter--plain + .chapter > .chapter__inner,
.chapter--plain + .chapter .chapter__grid,
.chapter--plain + .chapter .chapter__panel > .chapter__inner{ padding-top:var(--s-md); }
/* Two bands in a row are one run of content, not two. */
.chapter--plain + .chapter--plain{ margin-top:var(--s-md); }

/* --- the same gallery as a browser window --------------------------------
   Only the frame changes. The bezel and both radii already come from the
   component as custom properties, so a laptop is those numbers with the
   corners taken out — there is no second set of rules for the scrolling, the
   carousel or the hint.

   Two things do differ, and both follow from shape rather than taste. A wide
   frame reaches the point where only one fits across the page much sooner
   than a phone does, so the carousel threshold is raised; and the hint sits
   inside the frame rather than beside it, because there is no room to the
   side of something 1440 units wide. */
.smg--desktop .smg-device{ box-shadow:0 18px 40px -28px rgba(21,20,15,0.45); }
.smg--desktop .smg-caption{ text-align:left; }

/* Carousel — one frame at a time, settled by scroll snapping. Containment is
   on the horizontal axis only, so swiping between frames does not scroll the
   page sideways while vertical hand-off still works. */
.smg--carousel .smg-track{
  overflow-x:auto; overscroll-behavior-x:contain;
  scroll-snap-type:x mandatory; scrollbar-width:none;
  padding-bottom:0.25rem;
}
.smg--carousel .smg-track::-webkit-scrollbar{ display:none; }
.smg--carousel .smg-frame{ scroll-snap-align:center; }

.smg-pagination{ display:flex; align-items:center; gap:1rem; margin-top:var(--s-sm); }
.smg-count{ font-family:var(--mono); font-size:var(--t-caption); color:var(--ink-soft); }
.smg-dots{ display:flex; gap:0.5rem; }
.smg-dot{
  width:7px; height:7px; padding:0; border:0; border-radius:50%;
  background:var(--line-strong); opacity:0.25; cursor:pointer;
  transition:opacity .3s var(--ease);
}
.smg-dot.is-active{ opacity:1; }
.smg-dot:focus-visible{ outline:2px solid var(--signal); outline-offset:3px; }

/* Pre-mount / no-JS: every screen still readable, just not interactive. */
.smg-fallback{ display:flex; flex-wrap:wrap; gap:var(--s-md); align-items:flex-start; }
.smg-fallback-item{ margin:0; flex:1 1 260px; max-width:390px; }
.smg-fallback-item img{ display:block; width:100%; height:auto; border:1px solid var(--line); border-radius:22px; }

/* InsightStatement — oversized, deliberate pause in the narrative.
   Measures are set on the text itself: `ch` resolves against the element's own
   font-size, so putting it on the wrapper would size it to the body scale and
   choke display type down to a word per line. */
.cs-statement-label{ display:block; font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase; letter-spacing:0.16em; color:var(--ink-soft); margin-bottom:clamp(1.4rem,2.6vw,2.6rem); }
.cs-statement-text{ font-family:var(--serif); font-size:var(--t-statement); font-weight:420; line-height:1.06; letter-spacing:-0.014em; text-wrap:balance; max-width:15ch; }
.chapter:not(.chapter--statement) .cs-statement-text{ font-size:clamp(1.7rem,2.9vw,2.9rem); line-height:1.16; letter-spacing:-0.01em; max-width:20ch; }

/* Quote — italic, echoing the pull quotes on the homepage */
/* The measure belongs on the blockquote, not on the line of display type inside
   it. `ch` resolves against the element's own font, so any ch value written here
   is a count of the 40px italic — while .cs-prose's --measure counts the 18px
   sans. The two only agree where the display size happens to be double the body
   size, which is true around 1400px and nowhere else: at 768px the same 34ch cap
   came out at 529px against a paragraph running 707px, and the quote sat visibly
   short of every block above and below it.

   .cs-block--quote inherits the body font, so --measure there is the identical
   measure .cs-prose gets, and the quote's box now ends exactly where a
   paragraph's does at every width — including the media chapters, where blocks
   are 1223px wide and both are held back to the same reading measure.

   text-wrap is gone with it, and nothing replaces it. Balance evens the line
   lengths of a heading; on a four-line pulled quote it shortens every line to
   do so, which left the longest line 176px inside the box on a wide screen —
   the block was the right width and simply did not look it. `pretty` was no
   better here: it pulls words back off the earlier lines to avoid a short last
   one, and measured 593px against 686px for plain wrapping in the same box. A
   quote should break where a paragraph breaks, which means not asking for
   anything: a ragged last line is what every other block on the page does. */
.cs-block--quote{ max-width:var(--measure); }
.cs-quote-text{ font-family:var(--serif); font-style:italic; font-size:clamp(1.5rem,2.5vw,2.5rem); font-weight:420; line-height:1.24; letter-spacing:-0.01em; }
.cs-quote-cite{ display:block; margin-top:var(--s-sm); font-family:var(--mono); font-size:var(--t-caption); text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-soft); font-style:normal; }
.cs-quote-cite::before{ content:'— '; }

/* MetricSection — the numbers a project is judged on ----------------------
   These are the result of the work, so they are set as a headline rather than
   as a caption, and three things carry that.

   The lead number and its label sit on one baseline instead of stacked. The
   label under a 143px figure left the right two thirds of a 1223px band empty
   and the whole block reading as an afterthought; beside it, the pair occupies
   the width it was given.

   The rest are divided by hairlines rather than by gaps, so they read as one
   set of results and not as four numbers that happen to be near each other.
   Same device as the Challenge / Approach / Outcome row, which is the other
   place on the site where a few short things are compared across a band.

   Labels move to the mono uppercase every other label on the page uses —
   chapter numbers, captions, the summary keys. Sans lowercase was the only
   label on a case study that did not.

   Figures are tabular and each value reserves its own width in `ch`. The
   interaction layer counts these up from zero, and with proportional figures a
   number growing a digit would shove its label sideways mid-count. */
.cs-block--metrics{ display:flex; flex-direction:column; gap:var(--s-md); width:100%; }
.cs-metric-value{
  display:block; font-family:var(--serif); font-weight:420;
  font-size:clamp(2.6rem,5vw,5.4rem); line-height:0.96; letter-spacing:-0.02em;
  font-variant-numeric:lining-nums tabular-nums; font-feature-settings:'tnum' 1;
  min-width:calc(var(--digits, 0) * 1ch);
}
.cs-metric-label{
  margin-top:1rem; font-family:var(--mono); font-size:var(--t-label);
  text-transform:uppercase; letter-spacing:0.12em; line-height:1.65;
  color:var(--ink-soft); max-width:24ch;
}

/* The lead. Two things hold the band together: align-items:baseline, which
   puts the label on the figure's own baseline so the pair reads as one line
   rather than as a caption parked beside a number; and pushing that label to
   the far end, so the number and its name bracket the width instead of both
   huddling at the left with two thirds of a 1223px band left over. The rule
   underneath spans the whole thing and ties the two ends together. */
.cs-metric--lead{
  display:grid; grid-template-columns:auto minmax(0,1fr);
  column-gap:var(--s-md); align-items:baseline;
  padding-bottom:var(--s-md); border-bottom:1px solid var(--line-strong);
}
.cs-metric--lead .cs-metric-value{ font-size:clamp(4.5rem,12vw,11rem); letter-spacing:-0.03em; }
.cs-metric--lead .cs-metric-label{
  margin:0; justify-self:end; text-align:right; color:var(--ink);
  font-size:clamp(0.8rem,0.95vw,1rem); letter-spacing:0.14em; max-width:22ch;
}

/* One rule between neighbours, never a stray one at either end. The column
   count is written onto the row rather than guessed with auto-fit, because a
   rule has to know which cell ends a line — auto-fit does not say. Four is the
   ceiling: a fifth number wraps onto a second line, where nth-child(4n) still
   ends the row correctly. */
.cs-metric-row{ display:grid; column-gap:0; row-gap:var(--s-md); padding-top:var(--s-md); }
.cs-metric-row[data-cols="1"]{ grid-template-columns:minmax(0,1fr); }
.cs-metric-row[data-cols="2"]{ grid-template-columns:repeat(2,minmax(0,1fr)); }
.cs-metric-row[data-cols="3"]{ grid-template-columns:repeat(3,minmax(0,1fr)); }
.cs-metric-row[data-cols="4"]{ grid-template-columns:repeat(4,minmax(0,1fr)); }
.cs-metric-row .cs-metric{ padding-right:var(--s-md); padding-left:var(--s-md); border-right:1px solid var(--line); }
.cs-metric-row[data-cols="1"] .cs-metric,
.cs-metric-row[data-cols="2"] .cs-metric:nth-child(2n+1),
.cs-metric-row[data-cols="3"] .cs-metric:nth-child(3n+1),
.cs-metric-row[data-cols="4"] .cs-metric:nth-child(4n+1){ padding-left:0; }
.cs-metric-row[data-cols="1"] .cs-metric,
.cs-metric-row[data-cols="2"] .cs-metric:nth-child(2n),
.cs-metric-row[data-cols="3"] .cs-metric:nth-child(3n),
.cs-metric-row[data-cols="4"] .cs-metric:nth-child(4n),
.cs-metric-row .cs-metric:last-child{ border-right:none; padding-right:0; }

/* Two across below 900px — four columns of mono label is four columns of
   hyphenation. The rules follow, so the pairing stays legible rather than
   leaving a hairline hanging at the end of a row. */
@media (max-width:900px){
  .cs-metric-row[data-cols]{ grid-template-columns:repeat(2,minmax(0,1fr)); }
  .cs-metric-row[data-cols] .cs-metric{ padding-left:var(--s-md); padding-right:var(--s-md); border-right:1px solid var(--line); }
  .cs-metric-row[data-cols] .cs-metric:nth-child(2n+1){ padding-left:0; }
  .cs-metric-row[data-cols] .cs-metric:nth-child(2n),
  .cs-metric-row[data-cols] .cs-metric:last-child{ border-right:none; padding-right:0; }
}
/* On a phone the figure keeps the width and the label goes back underneath it:
   side by side, a 4.5rem number leaves the label about ten characters. */
@media (max-width:640px){
  .cs-metric--lead{ grid-template-columns:minmax(0,1fr); }
  .cs-metric--lead .cs-metric-label{ margin-top:0.9rem; max-width:none; justify-self:start; text-align:left; }
}

/* placeholders */
.cs-placeholder{ border:1px dashed var(--line); padding:var(--s-sm); display:flex; flex-direction:column; gap:0.5rem; }
.cs-placeholder-tag{ font-family:var(--mono); font-size:var(--t-caption); text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-soft); }
.cs-placeholder p{ font-size:0.92rem; color:var(--ink-soft); margin:0; }

/* --- NextProject ---------------------------------------------------------- */
.cs-end{ position:relative; z-index:3; background:var(--paper); border-top:1px solid var(--line-strong); padding:var(--s-xl) 0 0; }
.cs-end-inner{ max-width:var(--maxw); margin:0 auto; padding:0 var(--gutter); }
.cs-end-cta-block{ display:flex; flex-direction:column; align-items:flex-start; gap:var(--s-md); padding-bottom:var(--s-xl); }
.cs-end-line{ font-family:var(--serif); font-size:clamp(1.9rem,4.2vw,4rem); font-weight:420; line-height:1.1; letter-spacing:-0.013em; max-width:20ch; text-wrap:balance; }
.cs-end-cta{ display:flex; gap:1rem; flex-wrap:wrap; }
.cs-end-nav{ display:grid; grid-template-columns:1fr 1fr; border-top:1px solid var(--line); }
.cs-end-all{ border-top:1px solid var(--line); padding:var(--s-md) 0; text-align:center; margin-bottom:var(--s-lg); }
.cs-end-all a{ font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase; letter-spacing:0.14em; color:var(--ink-soft); border-bottom:1px solid transparent; transition:color .3s ease, border-color .3s ease; }
.cs-end-all a:hover{ color:var(--ink); border-color:var(--ink); }
.cs-end-nav--single{ grid-template-columns:1fr; }
.cs-end-link{ padding:var(--s-md) var(--s-md) var(--s-md) 0; display:flex; flex-direction:column; gap:0.9rem; transition:opacity .3s ease; }
.cs-end-link:hover{ opacity:.55; }
.cs-end-link--next{ text-align:right; align-items:flex-end; padding-right:0; padding-left:var(--s-md); border-left:1px solid var(--line); }
.cs-end-eyebrow{ font-family:var(--mono); font-size:var(--t-label); text-transform:uppercase; letter-spacing:0.12em; color:var(--ink-soft); }
.cs-end-title{ font-family:var(--serif); font-size:clamp(1.2rem,2.1vw,1.9rem); font-weight:420; line-height:1.2; letter-spacing:-0.008em; max-width:24ch; }
@media (max-width:720px){
  .cs-end-nav{ grid-template-columns:1fr; }
  .cs-end-link--next{ text-align:left; align-items:flex-start; padding-left:0; border-left:none; border-top:1px solid var(--line); }
}

.btn{ display:inline-flex; align-items:center; gap:0.5rem; padding:1rem 1.8rem; font-size:0.95rem; font-weight:500; border:1px solid var(--ink); transition:background-color .3s var(--ease), color .3s var(--ease); }
.btn-primary{ background:var(--ink); color:var(--paper); }
.btn-primary:hover{ background:transparent; color:var(--ink); }

/* --- footer --------------------------------------------------------------- */
.site-footer{ position:relative; z-index:3; padding:var(--s-md) 0; background:var(--ink); color:var(--paper); }
.footer-inner{ max-width:var(--maxw); margin:0 auto; padding:0 var(--gutter) 1.6rem; display:flex; justify-content:space-between; flex-wrap:wrap; gap:2rem; }
.footer-col p{ font-weight:600; font-size:0.9rem; margin-bottom:0.4rem; }
.footer-loc{ font-weight:400 !important; color:#B7B4A8; font-size:0.85rem !important; }
.footer-col--right{ display:flex; flex-direction:column; gap:0.5rem; text-align:right; }
.footer-col--right a{ font-size:0.88rem; color:#D6D3C7; transition:color .25s ease; }
.footer-col--right a:hover{ color:var(--paper); }
.footer-copy{ max-width:var(--maxw); margin:0 auto; padding:1.4rem var(--gutter) 0; font-family:var(--mono); font-size:var(--t-caption); color:#7A7768; border-top:1px solid #33322A; }

/* --- hidden on a phone ----------------------------------------------------
   An artefact the author has said only works at size. display:none rather
   than visibility or a zero height, so it costs no space and is not read out
   either — a screen reader on a phone should not announce a picture the page
   has decided not to show. */
@media (max-width:640px){ .cs-block--hide-mobile{ display:none; } }

/* wordmark:start — index.html keeps a copy of this block and syncs it from
   here; the sentinels are what makes replacing it exact rather than a guess at
   where the block ends. */
/* --- wordmark -------------------------------------------------------------
   The sparrow sizes in em, so it tracks the logo's own type rather than
   needing its own breakpoints. overflow:visible because the wing swings
   outside the 64-unit box at the top of its stroke and would otherwise be
   clipped mid-flap. */
/* The gap is measured from the beak tip, because the viewBox is cropped to
   the drawing — there is no built-in margin doing part of the work. */
.logo{ display:inline-flex; align-items:center; gap:0.6em; }
.logo-name::after{ content:'·'; color:var(--signal); margin-left:0.15em; }
/* The viewBox is cropped to the drawing rather than left at the exported
   0 0 64 64. That export carries about a third of its box in empty margin, so
   a mark sized in em came out a third smaller than the number said and the gap
   beside the name was mostly nothing. */
/* The nudge is optical, not arithmetic, and it is measured rather than guessed.
   align-items centres the mark's *box*, but the bird's mass — crown to belly —
   sits 3.5px above that box centre because the tail and legs hang below it,
   while the name's ink centre is higher again than the line box's. Measured in
   the page: the string's ink band against the body's band, the difference being
   -0.13em. Anything expressed in em holds when the logo's size changes. */
.logo-mark{ width:2.2em; height:auto; display:block; overflow:visible; flex:0 0 auto;
  transform:translateY(-0.13em); }
/* view-box, not the default border-box, and the coordinates are the drawing's
   own — the point where the top-front corner of the wing meets the body, just
   under the head. Found by rendering the mark large and rotating the wing about
   several candidates: hinged anywhere else it either swings the shoulder
   through the body or lifts the wing over the eye.

   The range either side of rest is not symmetric, and cannot be. The wing is
   drawn folded *inside* the body, so it has room to rise above the back but
   almost none to drop: past about -10 degrees the blue tip clears the belly and
   the bird looks broken. The beats below go up and barely dip. */
.logo-wing{ transform-box:view-box; transform-origin:39px 27px; }

/* Two names for one movement. An animation cannot be restarted by adding a
   rule that names the animation already on the element, so the arrival flap
   and the hover flap have to be distinct — otherwise the bird moves once on
   load and then never again.

   Two beats inside one run, the second smaller than the first, and a shallow
   dip between them: a bird does not make one slow stroke and stop. */
@keyframes sparrow-flap{
  0%,100%{ transform:rotate(0) }
  16%{ transform:rotate(26deg) }
  34%{ transform:rotate(-5deg) }
  52%{ transform:rotate(18deg) }
  74%{ transform:rotate(-3deg) }
}
@keyframes sparrow-flap-hover{
  0%,100%{ transform:rotate(0) }
  16%{ transform:rotate(26deg) }
  34%{ transform:rotate(-5deg) }
  52%{ transform:rotate(18deg) }
  74%{ transform:rotate(-3deg) }
}
/* Once on arrival, a beat after the page has settled, so it is seen at least
   once by a reader who will never hover — which is every reader on a phone.
   One run, because the two beats are inside it. ease-in-out per segment: the
   wing accelerates out of each turn rather than starting at full speed. */
.logo-wing{ animation:sparrow-flap .85s ease-in-out 1.1s 1 both; }
.logo:hover .logo-wing,
.logo:focus-visible .logo-wing{ animation:sparrow-flap-hover .85s ease-in-out 1; }

@media (prefers-reduced-motion: reduce){
  .logo-wing,
  .logo:hover .logo-wing,
  .logo:focus-visible .logo-wing{ animation:none; }
}
/* wordmark:end */
