@charset "utf-8";
/* ════════════════════════════════════════════════════════════════════════════

   johnpalmer.dev

   One page, four sections, two colours. The grid is the Figma comp's: a 1440
   frame with 26px margins, which is why --gutter is 1.806vw — 26/1440 — rather
   than a round number. Everything else scales off that.

   Order: tokens · reset · type · masthead · navigator · hero · work · press ·
   contact · footer · motion.

   ════════════════════════════════════════════════════════════════════════════ */

/* ── Fonts ─────────────────────────────────────────────────────────────────
   Self-hosted so the CSP can stay `default-src 'self'` and no third party sees
   a request. latin + latin-ext cover English, Spanish, French and German;
   Chinese falls through to whatever the platform ships, which is always better
   than a 5MB download. */
@font-face { font-family: Montserrat; font-weight: 400; font-style: normal; font-display: swap;
  src: url(/assets/fonts/montserrat-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Montserrat; font-weight: 400; font-style: normal; font-display: swap;
  src: url(/assets/fonts/montserrat-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
@font-face { font-family: Montserrat; font-weight: 500; font-style: normal; font-display: swap;
  src: url(/assets/fonts/montserrat-500-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Montserrat; font-weight: 500; font-style: normal; font-display: swap;
  src: url(/assets/fonts/montserrat-500-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
@font-face { font-family: Montserrat; font-weight: 700; font-style: normal; font-display: swap;
  src: url(/assets/fonts/montserrat-700-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
@font-face { font-family: Montserrat; font-weight: 700; font-style: normal; font-display: swap;
  src: url(/assets/fonts/montserrat-700-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }

/* ── Tokens ────────────────────────────────────────────────────────────── */
:root {
  --ink:    #000;
  --paper:  #fff;
  --panel:  #f5f5f5;
  --rule:   #d9d9d9;
  --mute:   #767676;          /* 4.54:1 on paper — the smallest passing grey */

  --gutter: clamp(18px, 1.806vw, 34px);
  --label:  11px;             /* fixed: it is a caption, not body copy */
  --display: clamp(30px, 3.472vw, 62px);
  --lead:   clamp(15px, 1.15vw, 19px);

  --rail-card: min(400px, 74vw);
  --masthead-h: 96px;

  /* One easing family, so nothing on the page moves in a different accent.
     --ease is the workhorse; --spring overshoots slightly for arrivals. */
  --ease:   cubic-bezier(.22, .61, .36, 1);
  --ease-io:cubic-bezier(.65, .02, .18, 1);
  --spring: cubic-bezier(.16, 1.1, .3, 1);

  --t-fast: .22s;
  --t-med:  .5s;
  --t-slow: .9s;

  color-scheme: light;
}

/* ── Reset ─────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

html {
  -webkit-text-size-adjust: 100%;
  /* All in-page motion is driven by router.js, which needs to own the easing.
     A CSS smooth-scroll here would fight it. */
  scroll-behavior: auto;
}

body {
  background: var(--paper);
  color: var(--ink);
  font: 400 var(--lead)/1.5 Montserrat, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
}

img, svg, canvas { display: block; max-width: 100%; }
img { height: auto; }
ul, ol { list-style: none; padding: 0; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
address { font-style: normal; }
:where(a, button, input, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
  border-radius: 2px;
}

.visually-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

.skiplink {
  position: fixed; top: 8px; left: 8px; z-index: 100;
  padding: 10px 16px; background: var(--ink); color: var(--paper);
  border-radius: 75px; font: 700 var(--label)/1 Montserrat, sans-serif;
  transform: translateY(-200%);
}
.skiplink:focus { transform: none; }

.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* ── Type ──────────────────────────────────────────────────────────────── */
.display {
  font-weight: 700;
  font-size: var(--display);
  line-height: 1.219;
  letter-spacing: -0.015em;
}

/* The 11px bold caption is the site's second voice: it labels everything the
   display type doesn't say. */
.label, .navtoggle, .langswitch, .hero__links, .press__links,
.card__name, .footer__copy, .footer__top, .navigator__foot {
  font-weight: 700;
  font-size: var(--label);
  line-height: 1.22;
  text-transform: uppercase;
}

/* ── Pills ─────────────────────────────────────────────────────────────── */
.pill {
  display: inline-flex; align-items: center; gap: 8px;
  height: 35px; padding: 0 16px;
  border-radius: 75px; border: 1px solid var(--ink);
  font: 400 var(--label)/1 Montserrat, sans-serif;
  text-transform: uppercase; white-space: nowrap;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.pill--solid { background: var(--ink); color: var(--paper); }
.pill--ghost { background: var(--paper); color: var(--ink); }
.pill__arrow { width: 12px; height: 10px; fill: currentColor; flex: none;
  transition: transform var(--t-fast) var(--ease); }

@media (hover: hover) {
  .pill--solid:hover { background: var(--paper); color: var(--ink); }
  .pill--ghost:hover { background: var(--ink); color: var(--paper); }
  .pill:hover .pill__arrow { transform: translateX(3px); }
}

/* ════ MASTHEAD ═══════════════════════════════════════════════════════════ */
.masthead {
  position: fixed; inset: 0 0 auto; z-index: 50;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: start;
  /* No column gap. The call button has to sit on exactly the same left edge as
     PALMER's stem below it, and a gap here would offset it by its own width.
     The space the mark needs is carried by the mark's own margin instead. */
  column-gap: 0; row-gap: 14px;
  padding: 42px var(--gutter) 0;
  pointer-events: none;                     /* the bar is air; its children aren't */
}
.masthead > * { pointer-events: auto; }

/* The mark occupies its slot from the start and only becomes visible past the
   hero, so nothing beside it moves when it arrives. */
.mark {
  display: flex; align-items: center; gap: 4px;
  height: 35px; padding-top: 6px;
  width: 51px; margin-right: -51px;         /* out of flow until .is-scrolled */
  transition: margin-right var(--t-med) var(--ease-io);
}
.mark__glyph { fill: var(--ink); height: 23px; }
.mark__j    { width: 14px; }
.mark__bolt { width: 11px; }
.mark__p    { width: 18px; }

/* Off-state. J and P sit low and transparent; the bolt is a hairline sliver. */
.mark__j, .mark__p {
  opacity: 0; transform: translateY(6px);
  transition: opacity var(--t-med) var(--ease), transform var(--t-med) var(--ease);
}
.mark__bolt { opacity: 0; transform: scaleY(.06); transform-origin: 50% 50%; }

.is-scrolled .mark { margin-right: 13px; }
.is-scrolled .mark__j { opacity: 1; transform: none; transition-delay: .04s; }
.is-scrolled .mark__p { opacity: 1; transform: none; transition-delay: .12s; }
/* The strike: a flash of scale past full, then a settle. Runs on the class
   flip rather than on scroll position so it reads as an event, not a scrub. */
.is-scrolled .mark__bolt { animation: strike .58s var(--spring) .26s both; }

@keyframes strike {
  0%   { opacity: 0; transform: scaleY(.06) scaleX(1.7); }
  22%  { opacity: 1; transform: scaleY(1.28) scaleX(.72); }
  46%  { opacity: 1; transform: scaleY(.92) scaleX(1.08); }
  70%  { opacity: 1; transform: scaleY(1.05) scaleX(.97); }
  100% { opacity: 1; transform: none; }
}

.masthead__book {
  justify-self: start;
  transition: transform var(--t-med) var(--ease-io);
}
.masthead__right {
  grid-column: 3;
  display: flex; flex-direction: column; align-items: flex-end; gap: 5px;
  padding: 1px 0 0 24px;          /* the gap the column no longer provides */
}

.navtoggle { display: inline-flex; align-items: baseline; gap: 2px; }
.navtoggle__bolt { width: 4px; height: 9px; fill: currentColor; align-self: center; }
.navtoggle span { position: relative; }
/* A rule that draws itself in from the left on hover and when the menu is up. */
.navtoggle span::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1px;
  background: currentColor; transform: scaleX(0); transform-origin: 0 50%;
  transition: transform var(--t-fast) var(--ease);
}
.navtoggle:hover span::after,
.navtoggle[aria-expanded="true"] span::after { transform: scaleX(1); }

/* ── Language offer ────────────────────────────────────────────────────────
   A one-line window that the next language slides up into. The reel holds two
   rows: the one on screen and the one arriving. */
.langswitch { display: block; text-align: right; }
.langswitch__reel { display: block; height: 1.22em; overflow: hidden; }
.langswitch__stack { display: block; }         /* two rows tall; slid by half */
.langswitch__row { display: block; height: 1.22em; line-height: 1.22em; white-space: nowrap; }
.langswitch:hover { text-decoration: underline; text-underline-offset: 3px; }

/* ════ NAVIGATOR ══════════════════════════════════════════════════════════ */
.navigator {
  position: fixed; inset: 0; z-index: 45;
  display: grid; align-content: center;
  padding: 0 var(--gutter);
  background: var(--paper);
}
.navigator[hidden] { display: none; }

.navigator__list { display: grid; gap: clamp(2px, .6vw, 10px); }
.navigator__list a {
  display: flex; align-items: baseline; gap: clamp(12px, 2vw, 32px);
  font-weight: 700; font-size: clamp(34px, 7vw, 104px); line-height: 1.08;
  letter-spacing: -0.025em;
  /* Each row wipes to grey from the left on hover — the same gesture as the
     NAVIGATOR rule, at display size. */
  background-image: linear-gradient(var(--mute), var(--mute));
  background-size: 0% 100%; background-repeat: no-repeat;
  -webkit-background-clip: text; background-clip: text;
  transition: background-size .45s var(--ease);
}
.navigator__list a:hover, .navigator__list a:focus-visible { background-size: 100% 100%; }
.navigator__list i {
  font-style: normal; font-size: var(--label); font-weight: 700;
  color: var(--mute); flex: none; align-self: flex-start; padding-top: .55em;
}
.navigator__list a[aria-current="true"] i { color: var(--ink); }

.navigator__foot {
  display: flex; gap: 24px; margin-top: clamp(28px, 5vh, 64px);
  color: var(--mute);
}
.navigator__foot a:hover { color: var(--ink); }

/* Rows deal in from below, staggered. --i is set by navigator.js. */
.navigator .navigator__list li, .navigator .navigator__foot {
  opacity: 0; transform: translateY(18px);
}
.navigator.is-open .navigator__list li,
.navigator.is-open .navigator__foot {
  opacity: 1; transform: none;
  transition: opacity .5s var(--ease) calc(var(--i, 0) * 55ms),
              transform .6s var(--spring) calc(var(--i, 0) * 55ms);
}
/* Four rows and a footer, so the deal order is written out rather than
   assigned from script — nothing here needs to be dynamic. */
.navigator__list li:nth-child(1) { --i: 0; }
.navigator__list li:nth-child(2) { --i: 1; }
.navigator__list li:nth-child(3) { --i: 2; }
.navigator__list li:nth-child(4) { --i: 3; }
.navigator__foot { --i: 4; }

/* The page must not scroll behind the overlay. */
.nav-open, .nav-open body { overflow: hidden; }

/* ════ SECTIONS ═══════════════════════════════════════════════════════════ */
/* One section at a time: each holds the full viewport and centres what it
   has, growing past it only when the content genuinely needs more. The top
   padding clears the fixed masthead. */
.section {
  min-height: 100svh;
  display: flex; flex-direction: column; justify-content: center;
  padding: calc(var(--masthead-h) + 12px) var(--gutter) clamp(40px, 7vh, 88px);
}
.section__head {
  display: flex; align-items: center; justify-content: space-between; gap: 20px;
}

/* ════ 01 · HERO ══════════════════════════════════════════════════════════ */
.hero {
  position: relative;
  display: grid;
  /* One explicit column. Without it the track is content-sized, and the
     block's `width: 100%` inside it resolves against nothing. */
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: 1fr auto;
  align-items: center;
  padding-bottom: 30px;
}

/* hero.js drops the WebGL canvas in here. It is opaque and paints the paper as
   well as the type, so the SVG below is simply covered. */
.hero__canvas {
  position: absolute; inset: 0; z-index: 1;
  width: 100%; height: 100%;
  background: var(--paper);
  pointer-events: none;
}

/* Wordmark and links as one optical unit, centred together. */
.hero__block { grid-row: 1; align-self: center; width: 100%; }
.hero__stage { width: 100%; }
/* The J's bowl hangs 5 units past the left edge of the viewBox, which is the
   optical alignment that makes it look flush with PALMER's flat P stem. The
   SVG viewport would clip that, so it does not clip. */
.hero__lockup { width: 100%; height: auto; color: var(--ink); overflow: visible; }

.hero__links, .hero__cue { position: relative; z-index: 2; }

.hero__links {
  /* Shrink to the three links. A full-width list would make the ink guard
     around it a full-width notch rather than a small one. */
  width: fit-content;
  display: flex; gap: 10px; align-items: baseline;
  /* Just under PALMER's baseline, on its left edge — not at the foot of the
     viewport. */
  margin-top: clamp(14px, 1.9vw, 30px);
}
.hero__links li[aria-hidden] { color: var(--mute); }
.hero__links a { position: relative; }
.hero__links a::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1px;
  background: currentColor; transform: scaleX(0); transform-origin: 0 50%;
  transition: transform var(--t-fast) var(--ease);
}
.hero__links a:hover::after { transform: scaleX(1); }

.hero__cue {
  grid-row: 2; justify-self: center;
  width: 13px; padding: 12px 16px; box-sizing: content-box;
}
.hero__cue svg { width: 13px; height: 27px; fill: var(--ink); }
.hero__cue:hover svg { animation: cue 1s var(--ease) infinite; }
@keyframes cue { 50% { transform: translateY(5px); } }

/* The wordmark is one lockup at every size. Breaking it onto three lines was
   tried and abandoned: PALMER is the longest word, so it sets the type size
   whichever way the lines fall — stacking bought no extra cap height, and cost
   JOHN its word spacing. On a phone the stage instead bleeds nearly to the
   glass, which is the only thing that does make the letters bigger. */
@media (max-width: 700px) {
  .hero__stage { margin-inline: calc((var(--gutter) - 9px) * -1); }
}

/* ════ 02 · WORK ══════════════════════════════════════════════════════════ */
.railnext {
  display: grid; place-items: center;
  width: 46px; height: 46px; flex: none;
  border-radius: 50%;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.railnext svg { width: 26px; height: 22px; fill: currentColor; }
.railnext:hover { background: var(--ink); color: var(--paper); }
.railnext[disabled] { opacity: .22; pointer-events: none; }

/* The rail bleeds off both edges: cards keep running past the margin, which is
   what says "there is more" without a scrollbar having to. */
.rail {
  margin: clamp(20px, 2.4vw, 34px) calc(var(--gutter) * -1) 0;
  padding-inline: var(--gutter);
  overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* Without this the snapport starts at the padding box, so the very first
     snap scrolls the left gutter out of view and card one sits flush against
     the window edge. */
  scroll-padding-inline-start: var(--gutter);
  scrollbar-width: none;
  overscroll-behavior-x: contain;
}
.rail::-webkit-scrollbar { display: none; }
.rail__track { display: flex; gap: 10px; width: max-content; }
.rail.is-dragging { cursor: grabbing; scroll-snap-type: none; }
.rail.is-dragging a { pointer-events: none; }

.card {
  position: relative; flex: none;
  width: var(--rail-card); aspect-ratio: 1;
  background: var(--ground, var(--panel));
  color: var(--label-ink, var(--ink));
  scroll-snap-align: start;
  overflow: hidden;
  isolation: isolate;
}

/* The whole card is a link. The overlay above it is visual only, so a click
   that lands between the two pills still opens the case study. */
.card__hit { position: absolute; inset: 0; z-index: 1; }

.card__art { position: absolute; inset: 0; overflow: hidden; }

/* One abstract element per card, drawn from the ground colour. What it is —
   a disc, a band, a glow, a set of rules — is set per project below. */
.card__shape { position: absolute; inset: 0; }

/* ── The devices ──────────────────────────────────────────────────────────
   A shared perspective, and then a different pose per card. A row of
   identically-angled phones reads as a template rather than as seven
   different pieces of work, so no two of these agree. */
.card__stage {
  position: absolute; inset: 0;
  perspective: 1400px; perspective-origin: 50% 45%;
  /* The devices bleed off the bottom on purpose; masking them rather than
     laying a coloured fade over the whole card means the artwork behind keeps
     its own colour all the way down. */
  mask-image: linear-gradient(180deg, #000 0 66%, transparent 97%);
}
.card__device {
  position: absolute;
  border-radius: 3.4%/1.6%;
  box-shadow: 0 3px 8px rgb(0 0 0 / .16), 0 30px 60px -18px rgb(0 0 0 / .45);
  will-change: transform;
}
.card__device--back { opacity: .92; }
.card__device--front { overflow: hidden; }
.card__device--front img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover;
  opacity: 0;
  transition: opacity 1.1s var(--ease);
}
.card__device--front img.is-on { opacity: 1; }

/* The float is on the stage rather than the device, so the pose set per card
   below is never overwritten by it. */
.card__stage { animation: float 11s ease-in-out infinite alternate; }
@keyframes float {
  from { transform: translateY(-1.2%); }
  to   { transform: translateY(1.2%); }
}

/* ── 1 · Where Am I — cool paper, one disc, the phone almost square on ──── */
.card--where-am-i { --ground: #C9D3DC; --label-ink: #0b0f14; }
.card--where-am-i .card__shape {
  background: radial-gradient(circle at 50% 100%, #E9EEF3 0 40%, transparent 40.4%),
              linear-gradient(160deg, #D7DFE7 0%, #AEBCC8 100%);
}
.card--where-am-i .card__device--front {
  left: 26%; top: 13%; width: 47%; aspect-ratio: 620/1347;
  transform: rotateX(6deg) rotateY(-9deg) rotateZ(-1.5deg);
}

/* ── 2 · Whimsy — the app's own yellow, a diagonal, phone and watch ─────── */
.card--whimsy { --ground: #FEEB2E; --label-ink: #171203; }
.card--whimsy .card__shape {
  background: linear-gradient(112deg, transparent 0 46%, #FFF6A6 46% 71%, transparent 71%),
              radial-gradient(circle at 84% 16%, #FFF9CF 0 22%, transparent 22.4%);
}
.card--whimsy .card__device--front {
  left: 16%; top: 15%; width: 45%; aspect-ratio: 620/1347;
  transform: rotateX(4deg) rotateY(13deg) rotateZ(3deg);
}
.card--whimsy .card__device--back {
  right: 8%; top: 42%; width: 25%; aspect-ratio: 324/394;
  border-radius: 22%/18%;
  transform: rotateY(-14deg) rotateZ(-5deg);
}

/* ── 3 · Gambit — near black, a single shaft of light, a hard tilt ──────── */
.card--gambit { --ground: #14181A; --label-ink: #f2f4f3; }
.card--gambit .card__shape {
  background: conic-gradient(from 210deg at 76% 8%, rgb(255 255 255 / .13) 0 16deg, transparent 16deg 360deg),
              radial-gradient(ellipse at 62% 30%, #262d30 0%, #0d1012 72%);
}
.card--gambit .card__device--front {
  left: 27%; top: 16%; width: 48%; aspect-ratio: 620/1347;
  transform: rotateX(9deg) rotateY(-19deg) rotateZ(2deg);
  box-shadow: 0 3px 10px rgb(0 0 0 / .5), 0 40px 70px -20px rgb(0 0 0 / .85);
}

/* ── 4 · Cookbook Press — deep warm brown, ruled like a page, two phones ── */
.card--cookbook-press { --ground: #2A1A14; --label-ink: #f6ece3; }
.card--cookbook-press .card__shape {
  background: repeating-linear-gradient(180deg, transparent 0 13px, rgb(246 236 227 / .07) 13px 14px),
              linear-gradient(200deg, #3A241B 0%, #1D110C 100%);
}
.card--cookbook-press .card__device--front {
  left: 32%; top: 18%; width: 43%; aspect-ratio: 620/1347;
  transform: rotateX(5deg) rotateY(-13deg) rotateZ(4deg);
}
.card--cookbook-press .card__device--back {
  left: 8%; top: 28%; width: 36%; aspect-ratio: 620/1347;
  transform: rotateX(5deg) rotateY(-13deg) rotateZ(-6deg) translateZ(-90px);
  filter: brightness(.66);
}

/* ── 5–7 · The three with nothing captured yet ───────────────────────────
   Their own vocabulary, set as the whole composition. Words light a few at a
   time, so the card reads as something running rather than a static list. */
.card__glyphs {
  position: absolute; inset: 0;
  display: flex; flex-wrap: wrap; align-content: center; justify-content: center;
  gap: .34em .5em; padding: 11%;
  font-weight: 700; font-size: clamp(10px, 1.45vw, 16px);
  line-height: 1.1; text-transform: uppercase; text-align: center;
}
.card__glyphs span { opacity: var(--glyph-rest, .38); transition: opacity .5s var(--ease); }
.card__glyphs span.is-lit { opacity: 1; }

.card--claude-for-safari { --ground: #0B0B0C; --label-ink: #fff; }
.card--claude-for-safari .card__shape {
  background: radial-gradient(ellipse at 30% 0%, #1d1d20 0%, #08080a 70%);
}
.card--claude-for-safari .card__glyphs {
  color: #fff; font-size: clamp(9px, 1.25vw, 14px);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: none;
}

.card--soccer-trainer { --ground: #ffffff; --label-ink: #000; }
.card--soccer-trainer .card__shape {
  background: repeating-linear-gradient(90deg, transparent 0 46px, #ededed 46px 47px),
              linear-gradient(#fff, #f2f2f2);
}
.card--soccer-trainer .card__glyphs {
  --glyph-rest: .5;
  color: var(--ink);
  align-content: space-evenly; justify-content: flex-start; text-align: left;
  font-size: clamp(13px, 2.1vw, 23px); letter-spacing: -.02em;
  transform: rotate(-4deg) scale(1.06);
}

.card--slate { --ground: #101012; --label-ink: #fff; }
.card--slate .card__shape {
  background: linear-gradient(150deg, #24242a 0%, #0a0a0c 62%);
}
.card--slate .card__glyphs {
  color: #fff; align-content: center; justify-content: center;
  font-size: clamp(12px, 1.95vw, 22px); gap: .3em .5em; padding: 16%;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: none;
  transform: rotate(3deg);
}

/* ── The name, and the two actions ───────────────────────────────────────── */
.card__name {
  position: absolute; left: 0; bottom: 0; z-index: 2;
  padding: 16px 18px;
  font: 700 var(--label)/1.22 Montserrat, sans-serif; text-transform: uppercase;
  color: var(--label-ink, var(--ink));
  text-shadow: 0 1px 12px rgb(0 0 0 / .18);
  /* Never in the way of the card-wide link underneath it. */
  pointer-events: none;
  transition: opacity var(--t-fast) var(--ease);
}

.card__actions {
  position: absolute; inset: 0; z-index: 3;
  display: flex; align-items: center; justify-content: center; gap: 10px;
  background: rgb(255 255 255 / .84);
  opacity: 0; visibility: hidden;
  pointer-events: none;
  transition: opacity var(--t-fast) var(--ease), visibility var(--t-fast);
}
.card__actions .pill {
  pointer-events: auto;
  transform: translateY(6px); transition: transform .32s var(--spring);
}
.card:hover .card__actions,
.card:focus-within .card__actions { opacity: 1; visibility: visible; }
.card:hover .card__name,
.card:focus-within .card__name { opacity: 0; }
.card:hover .card__actions .pill,
.card:focus-within .card__actions .pill { transform: none; }
.card__actions .pill:nth-child(2) { transition-delay: .05s; }

/* ── Clients ──────────────────────────────────────────────────────────────
   Order is shuffled per load, so no client is permanently first. */
.clients__title { margin-top: clamp(48px, 7vh, 92px); }

.marquee {
  margin: clamp(22px, 3vw, 42px) calc(var(--gutter) * -1) 0;
  /* The float moves each mark ±5px, and overflow clips at the padding edge —
     so the padding is what stops the tall logos being shaved top and bottom. */
  padding-block: clamp(12px, 1.6vw, 20px);
  overflow: hidden;
  /* Fade the ends rather than cut them — a hard edge reads as a bug. */
  mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent);
}
.marquee__lane { display: flex; width: max-content; will-change: transform; }
/* The plain-text stand-in clients.js replaces. Visible only if it never runs. */
.marquee__names { padding: 8px var(--gutter) 0; font-size: 14px; line-height: 1.7; color: var(--mute); }
.marquee__group { display: flex; align-items: center; gap: clamp(34px, 4.2vw, 66px); padding-right: clamp(34px, 4.2vw, 66px); }

/* The per-mark float is a Web Animation, set up in clients.js — a CSS
   animation would need a per-element duration, and writing one means writing
   an inline custom property, which `style-src 'self'` forbids. */
.marquee__item {
  display: grid; place-items: center;
  width: clamp(72px, 6vw, 96px); height: 58px;
}
.marquee__item img {
  /* An explicit cap, not `max-height: 100%`: the grid item centres the image,
     which leaves its height indefinite and the percentage with nothing to
     resolve against — so the tall marks came out half again the size of the
     wordmarks. A fixed ceiling also evens out their optical weight. */
  max-width: 100%; max-height: 44px; width: auto; height: auto;
  filter: grayscale(1) contrast(1.06);
  opacity: .74;
  transition: filter var(--t-med) var(--ease), opacity var(--t-med) var(--ease);
}
.marquee:hover .marquee__item img { opacity: .5; }
.marquee__item:hover img { filter: none; opacity: 1; }

/* ════ 03 · PRESS ═════════════════════════════════════════════════════════ */
.press__links {
  display: flex; flex-wrap: wrap; gap: 10px; align-items: baseline;
  margin-top: 18px;
}
.press__links li[aria-hidden] { color: var(--mute); }
.press__links a { position: relative; }
.press__links a::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1px;
  background: currentColor; transform: scaleX(0); transform-origin: 0 50%;
  transition: transform var(--t-fast) var(--ease);
}
.press__links a:hover::after { transform: scaleX(1); }

.press__figure {
  margin-top: clamp(20px, 2.2vw, 32px);
  border: 1px solid var(--rule);
  overflow: hidden;
  aspect-ratio: 1381 / 644;
}
.press__figure img {
  width: 100%; height: 100%; object-fit: cover; object-position: 50% 30%;
  /* reveal.js scrubs a paused Web Animation over this with the scroll. The
     static scale is what a reader with no JavaScript, or reduced motion, sees. */
  transform: scale(1.09);
}
@media (max-width: 700px) { .press__figure { aspect-ratio: 4 / 5; } }

/* ════ 04 · CONTACT ═══════════════════════════════════════════════════════
   Three ways in, in falling order of commitment: take a slot, write a line,
   post a letter. Each is separated by a ruled label rather than a box — the
   page has no boxes anywhere else and did not need them here.               */
.contact { text-align: center; }
.contact__ask { margin-bottom: clamp(30px, 5vh, 62px); }

/* The primary action, sized like one. */
.bigpill {
  display: inline-flex; align-items: center; gap: 14px;
  padding: 0 clamp(26px, 3vw, 42px); height: clamp(58px, 6.4vw, 82px);
  border-radius: 75px; background: var(--ink); color: var(--paper);
  font: 700 clamp(13px, 1.15vw, 17px)/1 Montserrat, sans-serif;
  letter-spacing: .02em; text-transform: uppercase; white-space: nowrap;
  transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.bigpill__bolt { width: 11px; height: 23px; fill: currentColor; flex: none; }
.bigpill .pill__arrow { width: 15px; height: 12px; }
@media (hover: hover) {
  .bigpill:hover { background: var(--paper); color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
  .bigpill:hover .pill__arrow { transform: translateX(4px); }
  /* The bolt strikes on hover — the same gesture as the masthead mark. */
  .bigpill:hover .bigpill__bolt { animation: strike .5s var(--spring); }
}

/* A hairline with its label sitting in a gap it cuts for itself. */
.rule {
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 16px;
  margin: clamp(34px, 5.5vh, 68px) auto clamp(22px, 3vh, 34px);
  width: 100%; max-width: 940px;
  font: 700 var(--label)/1.22 Montserrat, sans-serif;
  text-transform: uppercase; color: var(--mute);
}
.rule::before, .rule::after { content: ""; height: 1px; background: var(--rule); }

/* ── The letter ───────────────────────────────────────────────────────────
   One sentence with the fields set into it. The same three fields a stacked
   form would have, asked for the way a person would actually say them. */
.letter {
  width: 100%; max-width: 940px; margin: 0 auto; text-align: left;
  font-size: clamp(19px, 2.05vw, 33px); line-height: 1.5;
  letter-spacing: -.012em;
}
.letter__line { display: inline; }
.letter__line span { white-space: pre-wrap; }

.field {
  font: inherit; color: var(--ink); background: none;
  border: 0; border-bottom: 2px solid var(--rule);
  padding: 0 .12em .06em; border-radius: 0;
  transition: border-color var(--t-fast) var(--ease);
}
.field::placeholder { color: var(--mute); opacity: 1; }
.field:focus { outline: none; border-bottom-color: var(--ink); }
.field:focus-visible { outline: none; }

.field--name, .field--email {
  /* Grows with what is typed where the browser can; a sensible fixed measure
     everywhere else, which is most of Safari today. */
  width: 8.5ch; field-sizing: content; min-width: 6ch; max-width: 100%;
}
.field--email { width: 12ch; }
.field--message {
  display: block; width: 100%; margin-top: .5em;
  resize: none; field-sizing: content; min-height: 2.4em;
  border-bottom-width: 2px;
}
/* Something is missing: say so on the field, not in a paragraph elsewhere. */
.letter [data-invalid] { border-bottom-color: #c0261b; }

.letter__actions {
  display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
  margin-top: clamp(22px, 3vw, 38px);
}
.letter__status { font-size: 14px; font-weight: 500; line-height: 1.4; }
.letter__status[data-tone="bad"] { color: #c0261b; }

/* Off-screen rather than display:none — some bots skip what is hidden but
   fill what is merely out of view, which is exactly the signal we want. */
.letter__trap { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

.contact__where {
  width: 100%; max-width: 940px; margin: 0 auto;
  font-size: clamp(13px, 1.05vw, 16px); line-height: 1.7; color: var(--mute);
}
.contact__where b { font-weight: 700; color: var(--ink); }

@media (max-width: 640px) {
  .letter { font-size: clamp(18px, 5vw, 24px); }
  .contact__where { line-height: 1.9; }
}

/* ════ FOOTER ═════════════════════════════════════════════════════════════ */
.footer {
  position: relative;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 16px;
  padding: 30px var(--gutter) 34px;
  border-top: 1px solid var(--rule);
  background: linear-gradient(180deg, var(--paper), var(--panel));
}
.footer__top { grid-column: 3; justify-self: end; color: var(--mute); }
.footer__top:hover { color: var(--ink); }

.footer__bolts { display: flex; align-items: center; gap: 7px; }
.footer__bolt { fill: var(--ink); }
.footer__bolt--c { width: 13px; height: 27px; }
/* Hidden until struck. reveal.js drives the arrival through the Web
   Animations API — the timing is a sequence with overshoot, which a
   transition cannot express. */
.footer__bolt--l, .footer__bolt--r { width: 7px; height: 15px; opacity: 0; }
.footer__bolts.is-in .footer__bolt--l,
.footer__bolts.is-in .footer__bolt--r { opacity: 1; }

/* Narrow enough and the copyright line reaches the centred bolts, so the bolts
   take a row of their own. */
@media (max-width: 540px) {
  .footer {
    grid-template-columns: 1fr auto;
    grid-template-areas: "bolts bolts" "copy top";
    row-gap: 20px; justify-items: start;
  }
  .footer__copy  { grid-area: copy; }
  .footer__top   { grid-area: top; grid-column: auto; justify-self: end; }
  .footer__bolts { grid-area: bolts; justify-self: center; }
}

/* ════ BOOT ═══════════════════════════════════════════════════════════════
   One short entrance, once per tab. `boot` is on the markup so nothing flashes
   unstyled; boot.js swaps it for `ready` and the page deals itself in. */
.boot .masthead > *, .boot .hero__links, .boot .hero__cue { opacity: 0; }
.boot .hero__stage { opacity: 0; transform: translateY(2.5vh); }

.ready .masthead > *, .ready .hero__links, .ready .hero__cue {
  animation: fade-up .7s var(--ease) both;
}
.ready .masthead__book   { animation-delay: .30s; }
.ready .masthead__right  { animation-delay: .38s; }
.ready .mark             { animation-delay: .30s; }
.ready .hero__links      { animation-delay: .52s; }
.ready .hero__cue        { animation-delay: .62s; }
.ready .hero__stage      { animation: lockup .95s var(--ease-io) .06s both; }

@keyframes fade-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes lockup {
  from { opacity: 0; transform: translateY(2.5vh); clip-path: inset(0 0 100% 0); }
  to   { opacity: 1; transform: none;              clip-path: inset(0 0 0 0); }
}
/* Second and later views in the same tab skip straight to the end state. */
.ready.no-intro .masthead > *, .ready.no-intro .hero__links,
.ready.no-intro .hero__cue, .ready.no-intro .hero__stage { animation: none; opacity: 1; transform: none; }

/* Elements that enter as you reach them. */
[data-reveal] { opacity: 0; transform: translateY(22px); }
[data-reveal].is-in {
  opacity: 1; transform: none;
  transition: opacity .7s var(--ease) calc(var(--i, 0) * 70ms),
              transform .8s var(--spring) calc(var(--i, 0) * 70ms);
}

/* ════ MOTION ═════════════════════════════════════════════════════════════
   Everything decorative stops. What remains is the same page, still.        */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
  .boot .masthead > *, .boot .hero__links, .boot .hero__cue, .boot .hero__stage {
    opacity: 1; transform: none;
  }
  [data-reveal] { opacity: 1; transform: none; }
  .marquee__lane, .marquee__item, .card__shot { animation: none !important; }
  .press__figure img { transform: scale(1.09); }
}
