/* ============================================================
   Portfolio — design tokens
   Change these and the whole site changes with them.
   ============================================================ */

:root {
  /* Colour */
  --paper:       #f5f5f5;   /* page background            */
  --card:        #ffffff;   /* raised surfaces            */
  --ink:         #000000;   /* headings, primary text     */
  --ink-soft:    #4f4f4f;   /* body copy                  */
  --ink-faint:   #8a8a8a;   /* captions, meta labels      */
  /* Every hairline on the site: card outlines, the meta strip's
     dividers, form field underlines, the marquee's dash. Teal, so the
     accent draws the structure rather than a neutral grey. */
  --rule:        #0a7d70;   /* every 1px border           */
  /* The accent, used in small doses: inline links, focus rings, the
     current page in the menu. It is the same teal as the hero card so
     the palette reads as one colour used two ways — a surface and an
     accent — rather than two colours that nearly match.
     The blue this replaced sat at 2.75:1 on the page background, below
     AA for any text size. This clears 4.6:1. */
  --link:        #0a7d70;   /* inline links, focus, accents */
  --accent:      #0a7d70;   /* hero card / highlights     */
  --accent-edge: #064f46;   /* hero card border           */
  --accent-lift: #0e8a7c;   /* the lighter end of the gradient */
  /* The same teal, dilute, for mounting artwork. The screenshots are
     transparent, so whatever sits behind them is the mount: on bare paper
     they float, and on this they read as pinned to a surface. Roughly 18%
     of the accent over the page — at 6% it was there in the stylesheet and
     invisible on the screen, which is the same as not being there. */
  --wash:        #cbdfdd;   /* image mounts               */

  /* Type */
  --font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
  --font-ui:      "Karla", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-mono:    "DM Mono", ui-monospace, "SF Mono", Menlo, monospace;

  /* Shape */
  --r-sm: 8px;
  --r-md: 12px;
  --r-lg: 20px;
  --r-xl: 32px;
  --r-2xl: 48px;

  /* How far the floating navbar reaches past the content beneath it.
     Desktop only — on a phone there is no room to spare, so the bar sits
     on the same grid as everything else. */
  --nav-overhang: 26px;

  /* Motion */
  /* Both curves are measured off the reference site, not guessed.
     The entrance easing is a spring baked down to 45 sampled points —
     it overshoots to 1.0283 rather than settling straight onto 1, which
     is what gives the fade its slight push at the end. */
  /* Scroll reveals only. Ease-out, so the change is spread across the
     whole duration instead of front-loaded the way the spring is. */
  --ease-reveal: cubic-bezier(.22, .61, .36, 1);

  --ease-entrance: linear(0 0%, 0.0189 2.272727272727273%, 0.068 4.545454545454546%, 0.1376 6.818181818181818%, 0.2198 9.090909090909092%, 0.3082 11.363636363636363%, 0.3982 13.636363636363637%, 0.4859 15.90909090909091%, 0.569 18.181818181818183%, 0.6456 20.454545454545457%, 0.7146 22.72727272727273%, 0.7757 25.000000000000004%, 0.8287 27.272727272727277%, 0.8738 29.54545454545455%, 0.9116 31.818181818181824%, 0.9425 34.09090909090909%, 0.9673 36.36363636363637%, 0.9868 38.63636363636364%, 1.0016 40.909090909090914%, 1.0124 43.18181818181819%, 1.0199 45.45454545454546%, 1.0247 47.727272727272734%, 1.0274 50.00000000000001%, 1.0283 52.27272727272728%, 1.028 54.545454545454554%, 1.0268 56.81818181818183%, 1.0249 59.0909090909091%, 1.0227 61.363636363636374%, 1.0202 63.63636363636365%, 1.0176 65.90909090909092%, 1.0151 68.18181818181819%, 1.0127 70.45454545454545%, 1.0105 72.72727272727272%, 1.0085 75%, 1.0067 77.27272727272727%, 1.0052 79.54545454545453%, 1.0038 81.81818181818181%, 1.0027 84.0909090909091%, 1.0018 86.36363636363636%, 1.0011 88.63636363636363%, 1.0005 90.9090909090909%, 1 93.18181818181819%, 0.9997 95.45454545454545%, 0.9995 97.72727272727272%, 1 100%);
  --ease-mark: cubic-bezier(0.44, 0, 0.56, 1);

  /* Rhythm */
  --shell: 1400px;         /* max content width */
  --gutter: 48px;          /* page side padding */
  --section-gap: 120px;    /* space between case-study sections */
}

@media (max-width: 809px) {
  :root { --gutter: 24px; --section-gap: 72px; }
}

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

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: 17px;
  line-height: 1.6;
  letter-spacing: -0.01em;
  -webkit-font-smoothing: antialiased;
}

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

/* Without this, an <img> carrying width/height attributes keeps its
   attribute height while max-width shrinks the width — the picture is
   squashed rather than scaled. Deliberately not applied to svg: the
   inline icons size themselves from their own attributes. Anything that
   wants a fixed height (the boot mark, the owner logo) sets one at
   higher specificity and still wins. */
img, video { height: auto; }

a { color: inherit; }
p { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
h1, h2, h3, h4 { margin: 0; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }

:focus-visible {
  outline: 2px solid var(--link);
  outline-offset: 3px;
  border-radius: 4px;
}

/* Keyboard users meet this first and can jump past the header. Off screen
   until focused, then over everything, the sticky bar included. */
.skip-link {
  position: absolute;
  top: -100px;
  left: 16px;
  z-index: 100;
  padding: 10px 18px;
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: 999px;
  font-weight: 600;
  text-decoration: none;
}
.skip-link:focus { top: 16px; }

/* ============================================================
   Layout
   ============================================================ */

.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* A narrower column for reading — long prose never runs the full 1400px */
.column { max-width: 760px; }

.stack > * + * { margin-top: 20px; }

/* ============================================================
   Type roles
   ============================================================ */

/* Small uppercase mono label — the "CONTEXT" / "PROBLEMS" eyebrow */
.eyebrow {
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* Serif italic — used for the one statement per page that should feel written */
.display {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 400;
  letter-spacing: -0.025em;
  line-height: 1.18;
}

.h-xl { font-size: clamp(34px, 5.2vw, 60px); }
.h-lg { font-size: clamp(28px, 3.6vw, 42px); }
.h-md { font-size: clamp(22px, 2.4vw, 30px); }

.lede  { font-size: clamp(18px, 1.6vw, 21px); color: var(--ink-soft); }
.body  { color: var(--ink-soft); }
.caption {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.prose a { color: var(--link); text-decoration: underline; text-underline-offset: 3px; }

/* ============================================================
   Pill — social links, the Home button, tags
   ============================================================ */

.pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 11px 18px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  font-weight: 700;
  font-size: 15px;
  letter-spacing: -0.02em;
  text-decoration: none;
  transition: border-color .18s ease, color .18s ease, background-color .18s ease;
}

.pill:hover { border-color: var(--accent-edge); }
.pill:active { background: #ececec; }
.pill svg { width: 16px; height: 16px; }

/* Icon-only variant. Square, so a row of them reads as one set of
   controls rather than four labels of different widths. The name still
   has to exist for a screen reader — it moves to the link's aria-label. */
/* Teal outline on every icon control — the social marks, the Home
   button and the hamburger — so they read as one set. The glyph inside
   stays dark: an outline is an accent, a whole icon in accent colour is
   a different, louder thing. */
.pill--icon {
  width: 44px;
  height: 44px;
  padding: 0;
  justify-content: center;
  border-color: var(--accent);
}

.pill--icon:hover { border-color: var(--accent-edge); }
/* Brand marks fill their whole 24×24 box, where a stroked UI icon leaves
   padding inside it. Rendering them a shade smaller keeps the optical
   weight of the row even. */
.pill--icon svg { width: 17px; height: 17px; }

/* The home mark is a stroked outline, not a filled logo — it does not
   fill its 24×24 box the way a brand mark does, so at 17px it would sit
   visibly smaller than the icons it stands beside. */
.pill--home svg { width: 20px; height: 20px; }

.pill-row { display: flex; flex-wrap: wrap; gap: 10px; }

/* ============================================================
   Header
   ============================================================ */

/* The bar stays put while the page moves under it.

   The frosting needs a translucent background to blur THROUGH — with an
   opaque one, backdrop-filter has nothing to work on. 0.72 is chosen, not
   guessed: on the landing page the teal hero slides under this bar, and
   at that opacity the paper colour still dominates the blend, so the dark
   wordmark keeps its contrast. Thinner and the text sits on mid-teal.

   Browsers without backdrop-filter get a near-opaque bar instead — the
   text stays readable, it simply is not frosted. */
/* Transparent, and only there to stick. The gutter padding is what
   holds the bar off the screen edges. */
.masthead-bar {
  position: sticky;
  top: 0;
  z-index: 50;
  /* Pulled in by less than the page gutter, so the bar lands wider than
     the column it floats over. */
  padding: 14px calc(var(--gutter) - var(--nav-overhang));
}

/* The visible bar: a rounded rectangle floating over the page.

   Capped at the content width PLUS an overhang on each side, so it reads
   as a bar floating above the page rather than another card in the same
   column. Matching the window instead would lose the inset entirely.

   Browsers without backdrop-filter get a near-opaque bar. The teal hero
   slides under this on the landing page, and at .78 the paper colour
   still dominates the blend, so the dark wordmark keeps its contrast;
   thinner and the text would sit on mid-teal. */
.masthead {
  max-width: calc(var(--shell) - var(--gutter) * 2 + var(--nav-overhang) * 2);
  margin-inline: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 12px 20px;
  background: rgba(245, 245, 245, .94);
  border: 1px solid rgba(10, 125, 112, .18);
  border-radius: var(--r-lg);
  box-shadow: 0 6px 24px rgba(0, 0, 0, .05);
}

@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .masthead {
    background: rgba(245, 245, 245, .78);
    -webkit-backdrop-filter: blur(18px) saturate(1.15);
            backdrop-filter: blur(18px) saturate(1.15);
  }
}

.wordmark {
  font-family: var(--font-display);
  font-style: italic;
  font-size: 22px;
  letter-spacing: -0.03em;
  text-decoration: none;
  white-space: nowrap;
}

/* The city is set exactly as the menu links are — same family, size,
   weight and colour — so the two ends of the header read as one band of
   navigation rather than two different kinds of text. Only the wordmark
   stays in the display serif. The wordmark on one line, the city under
   it, at every width. */
.identity {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--ink-soft);
}

.identity-item { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.identity-item svg { width: 15px; height: 15px; opacity: .6; }

.identity-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 10px;
}

@media (max-width: 809px) {
  /* The menu collapses behind the button. This only applies once
     main.js has added the class — see the note on .menu-toggle. */
  .js-menu .menu-toggle { display: inline-flex; }

  /* Frosted to match the bar it hangs from, and lifted off the page with
     a soft shadow rather than a hard edge. z-index clears the bar itself
     so it can never be painted behind it. */
  .js-menu .menu-panel {
    position: absolute;
    top: calc(100% + 14px);
    right: 0;
    z-index: 60;
    flex-direction: column;
    align-items: stretch;
    min-width: 216px;
    padding: 10px;
    background: rgba(255, 255, 255, .94);
    border: 1px solid rgba(10, 125, 112, .22);
    border-radius: 20px;
    box-shadow:
      0 24px 48px -12px rgba(0, 0, 0, .22),
      0 2px 6px rgba(0, 0, 0, .06);
    /* Grows out of the button it belongs to, rather than appearing
       from nowhere in the middle of the corner. */
    transform-origin: top right;
    animation: menu-open .2s cubic-bezier(.2, .8, .3, 1);
  }

  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .js-menu .menu-panel {
      /* saturate() was the problem here, not opacity — contrast measured
         6:1 either way, but boosting the teal coming through tinted the
         whole panel and made the links read as washed out. Blur alone
         gives the frost; the colour underneath stays where it belongs. */
      background: rgba(255, 255, 255, .88);
      -webkit-backdrop-filter: blur(22px) saturate(1.1);
              backdrop-filter: blur(22px) saturate(1.1);
    }
  }

  .js-menu .menu-panel[hidden] { display: none; }

  /* 46px tall: a comfortable thumb target, which the old 12px padding
     was not. */
  .js-menu .menu-panel a {
    padding: 13px 16px;
    border-radius: 13px;
    font-size: 16px;
    color: var(--ink);
  }

  .js-menu .menu-panel a + a { margin-top: 2px; }
  .js-menu .menu-panel a:active { background: rgba(10, 125, 112, .14); }
}


/* ============================================================
   Menu
   ============================================================ */

.menu {
  display: flex;
  align-items: center;
  gap: 4px;
  position: relative;
}

.menu-panel {
  display: flex;
  align-items: center;
  gap: 4px;
}

/* Hidden until main.js unhides it. A hamburger that cannot be opened is
   worse than no hamburger, so with no script the panel simply stays a
   row of links. */
/* Icon only. The button's accessible name comes from aria-label, so
   nothing is lost by dropping the word. */
.menu-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid var(--accent);
  border-radius: var(--r-md);
  background: var(--card);
  color: var(--ink);
  cursor: pointer;
}

.menu-toggle[hidden] { display: none; }
.menu-toggle:hover { border-color: var(--accent-edge); }

/* Three bars from one element: the middle is the box, the outer two are
   its pseudo-elements. They rotate into a cross when the menu is open. */
.menu-bars,
.menu-bars::before,
.menu-bars::after {
  display: block;
  width: 18px;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform .22s ease, opacity .18s ease;
}

.menu-bars { position: relative; }

.menu-bars::before,
.menu-bars::after {
  content: "";
  position: absolute;
  left: 0;
}

.menu-bars::before { top: -6px; }
.menu-bars::after  { top: 6px; }

[aria-expanded="true"] .menu-bars { background: transparent; }
[aria-expanded="true"] .menu-bars::before { transform: translateY(6px) rotate(45deg); }
[aria-expanded="true"] .menu-bars::after  { transform: translateY(-6px) rotate(-45deg); }

.menu a {
  padding: 8px 14px;
  border-radius: var(--r-md);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.02em;
  text-decoration: none;
  color: var(--ink-soft);
  transition: color .18s ease, background-color .18s ease;
}

.menu a:hover { color: var(--ink); background: rgba(0,0,0,.045); }

/* The page you are on is stated, not just styled — aria-current is what
   a screen reader announces, and the weight change follows from it. */
.menu a[aria-current] {
  color: var(--link);
  background: rgba(10,125,112,.09);
}

/* ============================================================
   One-screen home

   The landing page is meant to arrive whole: header, statement, strip
   and the three cards, no scrolling. It is a flex column pinned to the
   viewport, and the hero is the only part that flexes — everything else
   is its natural height, so the slack collects in one place instead of
   stretching the cards out of proportion.

   `dvh` rather than `vh` because mobile browsers count the address bar
   in `vh`, which leaves the footer just off the bottom of the screen.

   It is min-height, not height. On a short window the hero stops at its
   floor and the page scrolls rather than crushing the cards — fitting is
   the intent, not a promise it can keep at every size.
   ============================================================ */

.fit {
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
}

.fit main {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* The hero is the only flexible part, so every other band is trimmed to
   leave it room. Its own padding is the floor it cannot shrink past —
   88px top and bottom was sized for a scrolling page and is what stopped
   this fitting at 900px tall. The floor came down again when the navbar
   started floating, which costs more height than a flush bar did.

   The flexing has to be passed down explicitly. The hero is a grandchild
   of <main>, not a child, so telling only the hero to grow does nothing:
   its wrapper stays its natural height and the slack pools at the bottom
   of the page instead. Both links in the chain need it. */
.fit .hero-band {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.fit .hero {
  flex: 1 1 auto;
  min-height: 200px;
  padding: 40px 32px;
}

.fit .masthead-bar { padding-block: 8px 2px; }
.fit .marquee  { margin-top: 16px; }
.work-head { padding-top: 32px; }
.fit .work-head { padding-top: 10px; }
.fit .work { padding-block: 8px 4px; }

/* One row rather than two: the icons and the credit line side by side
   save the ~60px that decided whether this fits. */
.fit .site-foot {
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  margin-top: 0;
  padding-block: 12px 14px;
  gap: 12px 20px;
}

.fit .site-foot .foot-line {
  flex: 1;
  justify-content: flex-end;
  gap: 12px 20px;
}

/* Below the fold rules apply anyway on a phone, where three stacked
   cards cannot fit a screen and should not try. */
/* Mobile landing page.

   The wordmark centred at the top, a compact statement card under it,
   then the projects stacked and read by scrolling.

   This does not fit one screen on a phone, unlike the desktop layout, and
   is not trying to. What it does promise is that the top of the first
   project card shows without scrolling, so a visitor skimming on a phone
   sees work on arrival and not just a sentence. `.fit` still sets
   min-height, so a short page fills the screen rather than floating.
   ------------------------------------------------------------------ */
@media (max-width: 809px) {
  /* Every page but the landing one: the way back on the left, the menu
     on the right, one row. Only the landing page centres its wordmark —
     centring a Home button leaves it floating in the middle of nothing. */
  /* No overhang on a phone — 24px of gutter is all there is. */
  .masthead-bar { padding: 10px var(--gutter); }

  .masthead {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 14px;
    text-align: left;
  }

  /* The landing page: wordmark centred, location under it. */
  .fit .masthead {
    position: relative;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    padding: 12px 14px;
    text-align: center;
  }

  .fit .identity { align-items: center; }
  .identity-meta { justify-content: center; gap: 2px 14px; }

  /* Out of the flow so the wordmark centres on the page rather than on
     the space left beside the button. Landing page only — elsewhere the
     row already puts it where it belongs. */
  .fit .menu {
    position: absolute;
    top: 14px;
    right: 0;
  }

  /* Sized to the statement, not the screen. It used to be a 56vh
     portrait slab that filled the whole first screen and pushed every
     project below the fold. min-height: 0 also drops the 382px desktop
     floor from `.hero`, which would otherwise do the same thing. */
  .fit .hero-band { flex: 0 0 auto; }

  .fit .hero {
    flex: 0 0 auto;
    height: auto;
    min-height: 0;
    padding: 36px 24px;
  }

  .fit .hero-statement { font-size: clamp(21px, 5.8vw, 28px); }

  .fit .marquee   { margin-top: 14px; }
  .fit .work-head { padding-top: 16px; }

  /* Back to a stacked column. The swipeable track existed only to make
     three cards fit one screen; nothing is fighting for height now. */
  /* No inline padding override here. `.shell` already supplies the page
     gutter, and zeroing it — a leftover from the swipeable track, which
     managed its own padding — ran the cards to the screen edges while the
     hero above them stayed inset. */
  .fit .work {
    display: grid;
    grid-template-columns: 1fr;
    gap: 16px;
    padding-block: 14px 24px;
    overflow: visible;
  }

  .fit .work-card { min-height: 0; }
  .fit .work-card .ph { flex: none; aspect-ratio: 4 / 3; }

  .fit .site-foot {
    flex-direction: column;
    align-items: flex-start;
    padding-block: 24px 40px;
  }
}

/* ============================================================
   Hero card — the coloured slab
   ============================================================ */

.hero {
  position: relative;
  overflow: hidden;
  min-height: 382px;
  display: grid;
  place-items: center;
  padding: 88px 32px;
  background: var(--accent);
  border: 1px solid var(--accent-edge);
  border-radius: var(--r-2xl);
  color: var(--on-accent, #fff);
  text-align: center;
}

.hero-statement {
  max-width: 730px;
  font-size: clamp(26px, 3.2vw, 37px);
  line-height: 1.2;
  letter-spacing: -0.025em;
  color: var(--on-accent, #fff);
}

/* ------------------------------------------------------------------
   Gradient field

   A base colour, a flat saturated wash over almost all of it, and blobs
   in a CONTRASTING hue roaming across — blurred hard, hard-light blended.
   The depth comes from that second, differently-hued colour, not from
   stacking up more blobs in the base colour's own family.

   Hard-light is per-channel: a source channel below 50% multiplies and
   darkens, above 50% it screens and lightens. Pale colours screen every
   channel and blow the card out to near-white, taking the white text
   with them. These darken the red and lift the green.

   The two blobs live on the pseudo-elements so each carries its OWN
   animation. Sharing one keyframe put them on a single timeline moving
   in lockstep, which reads as a static picture being nudged rather than
   something flowing. Periods that never line up (19s, 27s) mean the
   field never repeats a pose. Both loops are closed — last keyframe
   equals the first — and run `linear`; `alternate` eases to a dead stop
   at each end and back.
   ------------------------------------------------------------------ */

/* Defaults only: a project page sets these two on <body> from its own accent
   (lib/palette.js), and a value set on the element itself would win over that,
   leaving every case study wearing the site's teal under its own colour. */
:root {
  --field-wash:   #0d7d6e;
  --field-accent: #3d93c4;
}

/* Contains the blend. Without isolation, hard-light reaches past the
   card and blends with the page behind it. */
.hero,
.metric { isolation: isolate; }

.gradient {
  position: absolute;
  /* Pushed out past the card on every edge: a heavy blur on a shape that
     stops at the boundary leaves a visible soft edge just inside it. */
  inset: -20%;
  z-index: 0;
  pointer-events: none;
  background-color: var(--field-wash);
  mix-blend-mode: hard-light;
  opacity: .92;
}

/* The two blobs each carry their own animation so they never move in
   lockstep — periods that do not line up (19s, 27s) mean the field never
   repeats a pose. Both loops are closed, so there is no jump at the end
   of a cycle. */
.gradient::before,
.gradient::after {
  content: "";
  position: absolute;
  inset: 0;
  background-repeat: no-repeat;
  background-image: radial-gradient(closest-side circle, var(--field-accent), transparent 100%);
  filter: blur(70px);
}

.gradient::before {
  background-size: 56% 56%;
  animation: flow-a 19s linear infinite;
}

.gradient::after {
  background-size: 42% 42%;
  animation: flow-b 27s linear infinite;
}

@keyframes flow-a {
  0%   { background-position: 12% 22%; }
  25%  { background-position: 72% 8%;  }
  50%  { background-position: 86% 72%; }
  75%  { background-position: 26% 88%; }
  100% { background-position: 12% 22%; }
}

/* The other way round the card, so the two never travel together. */
@keyframes flow-b {
  0%   { background-position: 84% 78%; }
  25%  { background-position: 18% 82%; }
  50%  { background-position: 8%  24%; }
  75%  { background-position: 78% 16%; }
  100% { background-position: 84% 78%; }
}

@media (prefers-reduced-motion: reduce) {
  .gradient::before, .gradient::after { animation: none; }
}

.hero > *:not(.gradient) { position: relative; z-index: 2; }

/* ============================================================
   Marquee strip
   ============================================================ */

.marquee {
  overflow: hidden;
  margin-top: 24px;
  padding: 10px 0;
  background: #fcfdff;
  border: 1px dashed var(--rule);
  border-radius: var(--r-sm);
}

.marquee-track {
  display: flex;
  gap: 36px;
  width: max-content;
  animation: marquee 30s linear infinite;
}
/* Hover slows the strip rather than stopping it — see marquee() in
   main.js. It is done there because the speed has to change without the
   strip jumping: `animation-duration` recomputes progress from elapsed
   time, so raising it mid-loop snaps the track to a new position, and
   `animation-play-state` only has stopped and running. The Web Animations
   API's playbackRate changes rate while leaving position alone. */

.marquee-track li {
  font-family: var(--font-mono);
  font-size: 14px;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  white-space: nowrap;
}

@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* ============================================================
   Work grid — the case-study cards
   ============================================================ */

.work {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
  padding-block: 20px 96px;
}

@media (max-width: 1199px) { .work { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 809px)  { .work { grid-template-columns: 1fr; } }

/* A lone project in a multi-column grid sits at a fraction of the width
   with empty columns beside it, which reads as a page that failed to load
   the rest. Cap the single case to one sensibly sized column instead. The
   rule stops applying the moment a second project exists, so the grid
   comes back on its own.

   Scoped above the phone breakpoint on purpose: below it the grid is
   already a single column, and this selector matches `.fit .work` for
   specificity — unscoped it would win on source order and override the
   stacked landing-page layout for no gain. */
@media (min-width: 810px) {
  .work:has(> :only-child) { grid-template-columns: minmax(0, 420px); }
}

.work-card {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 12px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: var(--r-xl);
  text-decoration: none;
  transition: transform .25s ease, box-shadow .25s ease;
}

.work-card:hover {
  transform: translateY(-6px);
  box-shadow: 0 18px 40px rgba(0,0,0,.10);
}

.work-card .ph { border-radius: var(--r-lg); margin: 0; }

/* A real cover keeps the frame the placeholder drew. Without it the card has
   a border round the outside and one round the label, and the artwork between
   them floats unframed — the one element with nothing holding it.

   Tinted to match a case-study figure: the wash stops at the dashed edge, so
   the card's own white still shows in the ring between the two borders. The
   cover is a transparent export, so this is what sits behind the devices. */
.work-card .media {
  background: var(--wash);
  border: 1px dashed var(--accent);
  border-radius: var(--r-lg);
}

/* ------------------------------------------------------------------
   Owner

   The client's mark and name, sitting beside the project title rather
   than on the cover. No pill or border here: the card already has one,
   and a second enclosure around two small items reads as clutter. The
   name is set lighter and smaller than the title so the pair has an
   obvious order — project first, client second.
   ------------------------------------------------------------------ */

.work-owner-logo {
  flex: none;
  width: 18px;
  height: 18px;
  border-radius: 5px;
  object-fit: contain;
}

/* Dashed only while it is still a placeholder. Swap in a real
   <img class="work-owner-logo"> and this stops applying on its own. */
span.work-owner-logo {
  border: 1px dashed var(--accent);
  background: repeating-linear-gradient(45deg,
    rgba(0,0,0,.04) 0 4px, rgba(0,0,0,.08) 4px 8px);
}

/* A project with no client mark shows the product's own accent instead: a
   solid chip, so it has to shed the placeholder's dashes and stripes. Both
   class names are in the selector deliberately, to out-weigh the rule above
   rather than depend on sitting after it. */
span.work-owner-logo.work-owner-logo--accent {
  border: 0;
  background-image: none;
}

/* One pill, centred under the cover: the client's mark and the project
   name together, the way the reference site labels a card. `align-self`
   is what centres it — the card is a flex column, so the pill sizes to
   its own content and sits in the middle rather than stretching. */
.work-label {
  align-self: center;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  max-width: calc(100% - 24px);
  margin-bottom: 4px;
  padding: 9px 16px 9px 11px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: 999px;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: -0.02em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ============================================================
   Boot animation

   The logo mark held over the page while it gets ready — but no longer
   than it has to be, and never longer than three seconds.

   Three timings, and each exists for a different reason:

     floor   900ms  a splash that blinks in and out is worse than none,
                    so it holds at least this long even on localhost
     ready   ~load  the script dismisses it the moment the page is up
     cap    3000ms  a pure CSS keyframe with `forwards` fill

   The cap is the important one. It is CSS, not a timer, because a
   keyframe always runs and always ends where it is told — a full-screen
   cover that needs a callback to leave is one failed callback away from
   a site nobody can read. If the script never runs, the page still
   clears itself at three seconds.
   ============================================================ */

#boot {
  position: fixed;
  inset: 0;
  z-index: 10000;
  display: grid;
  place-items: center;
  background: var(--paper);
  animation: boot-out .55s ease 2.45s forwards;
}

/* The early exit, once the page reports itself ready. More specific than
   the rule above, so it takes over the running animation. */
#boot.is-done {
  animation: boot-dismiss .42s ease forwards;
}

/* Entrance, then a slow breath. Split in two so the mark looks right
   whether it is dismissed at 900ms or held to the full three seconds —
   a single long keyframe cut short in the middle does not. */
#boot img {
  width: 92px;
  height: 92px;
  animation: boot-in .5s cubic-bezier(.22,.9,.28,1) both,
             boot-breathe 1.9s ease-in-out .5s infinite;
}

@keyframes boot-in {
  from { opacity: 0; transform: scale(.82); }
  to   { opacity: 1; transform: none; }
}

@keyframes boot-breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.06); }
}

@keyframes boot-out {
  to { opacity: 0; visibility: hidden; pointer-events: none; }
}

/* Same destination, separate name — re-declaring a running animation's
   own name does not restart it, so the early exit needs its own. */
@keyframes boot-dismiss {
  to { opacity: 0; visibility: hidden; pointer-events: none; }
}

/* Already seen this visit — never rendered at all. */
.booted #boot { display: none; }

@media (prefers-reduced-motion: reduce) {
  #boot img { animation: boot-in .3s ease both; }
}

/* ============================================================
   Loading shimmer for heavy media

   Images and video are the only things here that need a good connection;
   text arrives with the HTML and is styled before first paint, so it
   never needs a placeholder.

   The shimmer is a BACKGROUND on the wrapper, sitting behind the image.
   Nothing is hidden and nothing has to be un-hidden — the image simply
   paints over the shimmer as it arrives. There is no state to get stuck
   in, which is why it is built this way rather than as a swapped class.

   Only on a slow connection: `slow-net` is set in the head from the
   Network Information API. Browsers without it never get the shimmer,
   which is the right default — a shimmer on a fast connection is a flash
   of grey nobody needed.
   ============================================================ */

picture { display: block; }

.media {
  position: relative;
  overflow: hidden;
  border-radius: inherit;
}

.media img,
.media video {
  display: block;
  width: 100%;
  height: auto;
}

.slow-net .media {
  /* The mount colour, so the skeleton sits on the same ground the loaded
     cover will, rather than flashing grey and then changing under it. */
  background-color: var(--wash);
  background-image: linear-gradient(90deg, transparent, rgba(255,255,255,.7), transparent);
  background-size: 200% 100%;
  background-repeat: no-repeat;
  animation: media-sweep 1.25s ease-in-out infinite;
}

/* main.js adds this once the cover has actually painted. Covers are
   transparent exports, so the sweep would otherwise run behind the artwork
   indefinitely — .slow-net is set at boot and never cleared. */
.slow-net .media.is-loaded {
  background-image: none;
  animation: none;
}

@keyframes media-sweep {
  from { background-position: -100% 0; }
  to   { background-position:  200% 0; }
}

@media (prefers-reduced-motion: reduce) {
  .slow-net .media { animation: none; }
}

/* ============================================================
   Media placeholder
   Swap <figure class="ph"> for an <img> when you have the real thing.
   ============================================================ */

.ph {
  position: relative;
  display: grid;
  place-items: center;
  aspect-ratio: var(--ratio, 4 / 3);
  margin: 0;
  background:
    repeating-linear-gradient(45deg,
      rgba(0,0,0,.035) 0 12px,
      rgba(0,0,0,.06)  12px 24px);
  /* Accent, so an empty slot reads as deliberate rather than broken. */
  border: 1px dashed var(--accent);
  border-radius: var(--r-lg);
  color: var(--ink-faint);
  text-align: center;
  overflow: hidden;
}

.ph::after {
  content: attr(data-label);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 8px 14px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: 999px;
}

.ph--wide  { --ratio: 16 / 9; }
.ph--tall  { --ratio: 3 / 4; }
.ph--square{ --ratio: 1 / 1; }

/* A figure with a caption under it */
.figure { margin: 0; }
.figure > .caption { display: block; margin-top: 12px; }

/* A real image takes over the slot the placeholder held, so it gets the
   placeholder's corners — var(--r-lg), the same token .ph uses — and
   fills the column at its own aspect ratio. `height: auto` is what keeps
   it from stretching; no object-fit, because cropping a screenshot to
   fit a ratio loses the top or bottom of a phone screen. Export the file
   at the ratio you want and it lands undistorted.

   The exports carry an alpha channel and no ground of their own, so the
   page shows through and only this border draws the edge. That is the
   whole reason for the border: without it a transparent export has no
   boundary at all and the devices float loose on the paper. */
/* An image dropped straight into a figure, with no mount around it. */
.figure img,
.figure video {
  width: 100%;
  height: auto;
  background: var(--wash);
  border: 1px solid var(--rule);
  border-radius: var(--r-xl);
}

/* The mount reproduces a project card exactly: an outer solid edge at
   --r-xl, twelve pixels of ground, then a dashed frame at --r-lg around
   the artwork itself. The card gets its two edges from .work-card and
   .media; a figure has only the one element, so it needs the wrapper to
   have anywhere to put the second. */
.fig-mount {
  padding: 12px;
  /* No fill: the ring between the two edges stays page colour, so the tint
     reads as the artwork's mount rather than as a tinted panel. */
  border: 1px solid var(--rule);
  border-radius: var(--r-xl);
}

.fig-mount img,
.fig-mount video {
  display: block;
  width: 100%;
  height: auto;
  /* The tint sits here, inside the dashed frame, and shows through the
     transparent parts of the export. */
  background: var(--wash);
  border: 1px dashed var(--accent);
  border-radius: var(--r-lg);
}

/* ------------------------------------------------------------------
   A live prototype sitting in a figure slot

   The embedded app runs in `?bare=1`, which drops its screen index and
   renders the device alone at a fixed 406x860. Fixed is the point — a
   phone that reflows to 280px is no longer showing you a phone — so on a
   narrow column the frame is scaled rather than resized. CSS cannot
   derive a unitless scale from a length, so the steps are fixed: 406px
   plus the 24px page gutters needs about 454px of viewport, which is
   where the first step sits.

   The wrapper carries the radius and the clipping, because the transform
   on the iframe would otherwise paint straight through a rounded corner,
   and its height tracks the scale so no dead space is left underneath.
   ------------------------------------------------------------------ */
/* Stacked by default — copy above, device below. The two-column split is
   the desktop case, further down. */
.demo-split { display: grid; gap: 28px; }

.demo-copy > * + * { margin-top: 14px; }
.demo-copy h3 { font-size: 22px; }

.demo-stage {
  /* The device is 406x860; bare mode frames it in 16px, so the iframe has
     to be 438x892 or the phone overflows and gets clipped on the right. */
  --demo-w: 438px;
  --demo-h: 892px;
  --demo-scale: 1;

  position: relative;
  display: flex;
  justify-content: center;
  overflow: hidden;
  /* Both track the scale, so the box hugs the device instead of leaving a
     slab of black around it — which reads as a broken image, not a frame. */
  width: calc(var(--demo-w) * var(--demo-scale));
  height: calc(var(--demo-h) * var(--demo-scale));
  margin-inline: auto;
  /* No frame at all here: the device has a bezel of its own, and a second
     rounded edge a few pixels outside it reads as a mistake. This element
     survives only to size and clip the scaled iframe. */
  background: transparent;
  border: 0;
}

.demo-stage iframe {
  flex: none;
  width: var(--demo-w);
  height: var(--demo-h);
  border: 0;
  /* An iframe paints white by default even when its document does not. */
  background: transparent;
  transform: scale(var(--demo-scale));
  transform-origin: top center;
  transition: opacity 420ms var(--ease-reveal);
}

/* While main.js loads the prototype ahead of the reader, the frame is held
   back until the app inside has painted, so it never boots in plain view.
   `is-loading` only exists if the script added it, and the script always
   takes it off again, so without JavaScript the frame shows as normal. */
.demo-stage.is-loading iframe { opacity: 0; }

/* A quiet stand-in the shape of the phone, inset by the 16px the bare
   demos frame themselves in, so the space doesn't read as missing. */
.demo-stage::before {
  content: "";
  position: absolute;
  inset: calc(16px * var(--demo-scale));
  border-radius: calc(52px * var(--demo-scale));
  background: var(--wash);
  opacity: 0;
  transition: opacity 420ms var(--ease-reveal);
  pointer-events: none;
}
.demo-stage.is-loading::before { opacity: 1; }

/* 438px plus the 24px page gutters needs 486px of viewport to sit unscaled.
   Each step is the largest scale that still fits the narrowest width it
   covers: .74 holds down to 372px, .62 down to 319px. */
@media (max-width: 485px) { .demo-stage { --demo-scale: .74; } }
@media (max-width: 371px) { .demo-stage { --demo-scale: .62; } }

/* Desktop: the device to the right, what it is to the left. 950px is where
   a 438px frame plus a readable measure beside it both fit; below that the
   text would be squeezed into a column narrower than the phone. */
@media (min-width: 950px) {
  .demo-split {
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 56px;
    align-items: center;
  }
  .demo-split .demo-stage { margin-inline: 0; }
}

/* ============================================================
   Case-study page
   ============================================================ */

.case-hero {
  padding: 96px 40px 108px;
  text-align: center;
}
.case-hero .eyebrow { color: var(--on-accent, #fff); opacity: .92; }
/* Same display serif as the landing page's statement — these two are the
   only headline-scale texts on teal, and they should read as a pair. */
.case-hero h1 {
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 400;
  letter-spacing: -0.025em;
  font-size: clamp(36px, 5.5vw, 64px);
  color: var(--on-accent, #fff);
}
/* Large-text weight and size on purpose: the field behind it holds
   3.8:1 against white, which is AA for large text but not for body. The
   serif changes the look, not that rule — 600 at 20px still counts as
   large text, which is why the weight stays put and why Fraunces is
   requested at 600 rather than synthesised up from 500. */
.case-hero .lede {
  max-width: 620px;
  margin-inline: auto;
  color: var(--on-accent, #fff);
  font-family: var(--font-display);
  font-style: italic;
  font-size: clamp(20px, 2.1vw, 24px);
  font-weight: 600;
  letter-spacing: -0.02em;
}

/* Client / duration / date / role */
/* Four across by default. A list with fewer entries sets --cols to its
   own count, or the empty cells show the rule colour behind the grid as
   a solid block. */
.case-meta {
  display: grid;
  grid-template-columns: repeat(var(--cols, 4), 1fr);
  gap: 1px;
  margin-top: 24px;
  background: var(--rule);
  border: 1px solid var(--rule);
  border-radius: var(--r-md);
  overflow: hidden;
}
.case-meta div { padding: 20px 24px; background: var(--card); }
.case-meta dt { font-family: var(--font-mono); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-faint); }
.case-meta dd { margin: 6px 0 0; font-weight: 700; letter-spacing: -0.02em; }

@media (max-width: 809px) { .case-meta { grid-template-columns: repeat(2, 1fr); } }

.section { padding-block: calc(var(--section-gap) / 2); }
.section > * + * { margin-top: 28px; }

/* Numbered / bulleted problem list */
.list-marked li {
  position: relative;
  padding-left: 28px;
  color: var(--ink-soft);
}
.list-marked li + li { margin-top: 16px; }
.list-marked li::before {
  content: "";
  position: absolute;
  left: 0; top: .62em;
  width: 10px; height: 10px;
  background: var(--accent);
  border: 1px solid var(--accent-edge);
  border-radius: 3px;
}
.list-marked strong { color: var(--ink); font-weight: 700; }

/* The single big claim of the project */
.statement {
  padding: 56px 40px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: var(--r-xl);
  text-align: center;
}
.statement-text { margin: 16px auto 0; max-width: 900px; }

/* Three principles side by side */
.trio {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 20px;
}
/* Three principles still fit side by side on a tablet — only a phone
   needs them stacked. */
@media (max-width: 809px) { .trio { grid-template-columns: 1fr; } }

.trio-card {
  padding: 28px;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: var(--r-lg);
}
.trio-card .num {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--ink-faint);
}
.trio-card h3 { margin: 12px 0 10px; font-size: 20px; }

/* Two media panels side by side */
.pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}
@media (max-width: 809px) { .pair { grid-template-columns: 1fr; } }

/* Result / metric callout */
.metric {
  position: relative;
  overflow: hidden;
  padding: 48px 40px;
  background: var(--accent);
  border: 1px solid var(--accent-edge);
  border-radius: var(--r-xl);
  color: var(--on-accent, #fff);
  text-align: center;
}
.metric .eyebrow { color: var(--on-accent, #fff); opacity: .92; }
.metric p:last-child { margin-top: 16px; font-size: clamp(22px, 2.8vw, 34px); }
.metric > *:not(.gradient) { position: relative; z-index: 2; }

/* Next project */
.next {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding: 40px;
  margin-top: var(--section-gap);
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: var(--r-xl);
  text-decoration: none;
  transition: transform .2s ease;
}
.next:hover { transform: translateY(-3px); }
.next-title { display: block; margin-top: 8px; }
@media (max-width: 809px) { .next { flex-direction: column; align-items: flex-start; } }


/* ============================================================
   Contact form

   Underline fields rather than boxes: a row of outlined boxes competes
   with the cards and the meta strip elsewhere on the site, where a rule
   under the text reads as a place to write and nothing more.
   ============================================================ */

.form {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 40px 32px;
  max-width: 900px;
}

@media (max-width: 809px) { .form { grid-template-columns: 1fr; gap: 32px; } }

.field { display: flex; flex-direction: column; gap: 10px; }
.field--wide { grid-column: 1 / -1; }

.field label {
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.field input,
.field textarea {
  width: 100%;
  padding: 6px 0 12px;
  border: 0;
  border-bottom: 1px solid var(--rule);
  border-radius: 0;
  background: transparent;
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: 18px;
  letter-spacing: -0.01em;
  transition: border-color .18s ease;
}

/* Starts one line tall, like the name field, and grows with what is
   typed. field-sizing does it in CSS where supported; main.js sizes it
   by hand everywhere else. The cap stops a long message pushing the send
   buttons off the screen; past it the box scrolls. */
.field textarea {
  field-sizing: content;
  resize: none;
  max-height: 50vh;
  overflow-y: auto;
  line-height: 1.6;
}

.field input::placeholder,
.field textarea::placeholder { color: var(--ink-faint); }

/* The underline thickening IS the focus indicator, so it has to be
   unmistakable — this replaces the outline rather than removing it. */
.field input:focus,
.field textarea:focus {
  outline: none;
  border-bottom-color: var(--accent);
  box-shadow: 0 1px 0 0 var(--accent);
}

/* Only after the browser has judged it, so an untouched field is never
   marked wrong. */
.field input:user-invalid,
.field textarea:user-invalid {
  border-bottom-color: var(--accent-edge);
  box-shadow: 0 1px 0 0 var(--accent-edge);
}

.form-actions {
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.send-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.form-hint {
  max-width: 46ch;
  font-size: 15px;
  color: var(--ink-soft);
  text-align: center;
}

.btn-send {
  display: inline-flex;
  align-items: center;
  gap: 14px;
  padding: 20px 44px;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: transparent;
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color .2s ease, color .2s ease, background-color .2s ease;
}

.btn-send:hover { border-color: var(--link); color: var(--link); background: rgba(10,125,112,.05); }
.btn-send:active { background: rgba(0,0,0,.07); }
.btn-send svg { width: 18px; height: 18px; transition: transform .2s ease; }
.btn-send:hover svg { transform: translateX(4px); }

/* Empty until there is something to say, so it takes no space. */
.form-note:empty { display: none; }

.form-note {
  font-size: 15px;
  color: var(--ink-soft);
  text-align: center;
}

.form-note[data-state="error"] { color: var(--accent-edge); font-weight: 600; }
.form-note[data-state="ok"] { color: var(--accent); font-weight: 600; }

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

.site-foot {
  display: flex;
  flex-direction: column;
  gap: 28px;
  /* No rule above this. The space does the separating — the muted colour
     and the size already mark it as a different kind of content. */
  padding-block: 48px 72px;
  margin-top: 40px;
  font-size: 14px;
  color: var(--ink-faint);
}

/* Name and credit on one line, splitting to two rows on a phone. */
.foot-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px 20px;
}

/* ============================================================
   Entrance animation

   Pure CSS, and deliberately so. An earlier version hid every section and
   waited for an IntersectionObserver callback to show it again — when that
   callback didn't fire, the page stayed blank. A keyframe animation has no
   callback to miss: it always runs and always ends at the element's natural
   state, so the worst case here is "no animation", never "no page".

   `backwards` fill is what applies the opening frame during the delay.
   ============================================================ */

/* Everything arrives from ABOVE and settles, rather than rising into
   place — the page reads as things landing on it.

   How the scroll trigger works, and why it is built this way:

   The animation is declared here and runs by default. Arming an element
   for scroll does NOT hide it with `display` or `opacity` — it PAUSES the
   animation, which `backwards` fill holds at its opening frame. Playing it
   is then one custom property away.

   `--play` inherits, which is the whole trick: pausing a container pauses
   every part inside it — the meta strip's labels, a split sentence's words
   — without naming any of them here.

   And the default is `running`. If main.js never arms anything, every
   element animates on load exactly as it did before. Nothing can be left
   waiting for a callback that isn't coming. */
.reveal {
  /* Slower and softer than the letter animation on purpose: this one
     plays while the page is moving under it, so it needs to still be
     fading when it arrives rather than being over on contact. */
  animation: drop-in 900ms var(--ease-reveal) backwards;
  animation-play-state: var(--play, running);
}

.reveal.is-armed { --play: paused; }
.reveal.is-in    { --play: running; }

/* Stagger a row of cards so it arrives as a group, not a rank. */
.reveal[data-reveal-delay="90"]  { animation-delay: .09s; }
.reveal[data-reveal-delay="180"] { animation-delay: .18s; }

/* Two entrances, both measured rather than guessed.

   The reference site writes its animations' opening frames straight into
   the HTML as inline styles, and there are exactly two:

     opacity: 0.001; transform: translateY(-20px)   — everything general
     opacity: 0.001; transform: translateX(-35px)   — the wordmark

   So blocks fall 20px into place and the wordmark comes in 35px from the
   left. The statement's word spans carry no inline style at all, which is
   why those alone are a pure fade. */
@keyframes drop-in {
  from { opacity: 0; transform: translateY(-20px); }
  to   { opacity: 1; transform: none; }
}

@keyframes slide-in-left {
  from { opacity: 0; transform: translateX(-35px); }
  to   { opacity: 1; transform: none; }
}

/* Words only. No transform, because their spans have none. */
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* The wordmark comes in from the side instead, so the identity line and
   the hero below it don't all move on the same axis at the same moment. */
.wordmark { animation: slide-in-left 400ms var(--ease-mark) backwards; }

/* ------------------------------------------------------------------
   The meta strip animates its PARTS, not itself

   Label drops, value follows a beat behind it, and each cell starts
   after the one before — so the strip fills in left to right rather
   than appearing as one slab. The cells themselves hold still; it is
   the text that lands in them.
   ------------------------------------------------------------------ */

.case-meta { animation: none; }

.case-meta dt,
.case-meta dd {
  animation: drop-in 700ms var(--ease-reveal) backwards;
  animation-play-state: var(--play, running);
}

.case-meta > div:nth-child(1) dt { animation-delay: 0s;   }
.case-meta > div:nth-child(1) dd { animation-delay: .10s; }
.case-meta > div:nth-child(2) dt { animation-delay: .12s; }
.case-meta > div:nth-child(2) dd { animation-delay: .22s; }
.case-meta > div:nth-child(3) dt { animation-delay: .24s; }
.case-meta > div:nth-child(3) dd { animation-delay: .34s; }
.case-meta > div:nth-child(4) dt { animation-delay: .36s; }
.case-meta > div:nth-child(4) dd { animation-delay: .46s; }

/* ------------------------------------------------------------------
   Word-by-word drop

   These rules only bite under .is-split, and .is-split only exists if
   main.js successfully wrapped each word. No script, no spans, no
   hiding — the sentence is simply there, which is the whole point.
   No overflow mask: this type is italic with real descenders, and
   clipping to the line box eats them.
   ------------------------------------------------------------------ */

/* The wrapper only keeps a word whole. An inline-block letter is its own
   break opportunity, so without this a line could end mid-word. */
.is-split .word {
  display: inline-block;
  white-space: nowrap;
}

/* Each letter, 450ms apart by 20ms — both read off the reference site's
   own running animations, where the visible copy's delays step 0, 20, 40,
   60 … one per character. Opacity only: their keyframes carry no
   transform, and the settled letters have none either. */
/* The metric paragraph is 286 characters — nearly three times the hero
   statement. At the standard 20ms step it takes over six seconds to
   finish, and it sits below the fold, so it would still be washing in
   long after the reader had got to the end of it. A tighter step keeps
   the same motion and brings it to roughly two and a half seconds.

   Scoped to this one block: everywhere else the measured 20ms stands. */
.metric .display .char {
  animation-delay: calc(var(--i, 0) * 7ms);
}

.is-split .char {
  display: inline-block;
  animation: fade-in 450ms var(--ease-entrance) backwards;
  animation-play-state: var(--play, running);
  animation-delay: calc(var(--i, 0) * 20ms);
}

@keyframes menu-open {
  from { opacity: 0; transform: translateY(-8px) scale(.96); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .js-menu .menu-panel { animation: none; }
  .reveal,
  .wordmark,
  .case-meta dt,
  .case-meta dd,
  .is-split .char { animation: none; }
}

