/* ============================================================
   site.css for jackcrombie30.com
   Single stylesheet for all 5 pages. No build step, no framework.

   Structure:
     1.  Tokens
     2.  Reset + base
     3.  Typography
     4.  Layout primitives
     5.  Header / nav
     6.  Hero
     7.  Stat strip
     8.  Door cards (home)
     9.  Section headings
     10. Media (photo + video panels)
     11. Data list (measurables, academics)
     12. Project list (building page)
     13. Gallery
     14. Footer
     15. Motion + reduced motion
   ============================================================ */

/* ============================================================
   1. TOKENS
   Warm near-black base so photography carries the colour.
   Accent hue 75 (gold) sits far outside the 200-280 blue/purple
   zone every generated site lands in.
   ============================================================ */
:root {
  --hue: 62;

  --bg-page:     oklch(0.145 0.008 var(--hue));
  --bg-card:     oklch(0.185 0.008 var(--hue));
  --bg-elevated: oklch(0.225 0.009 var(--hue));

  --border:        oklch(1 0 0 / 0.10);
  --border-strong: oklch(1 0 0 / 0.18);

  --text-1: oklch(0.960 0.005 var(--hue));
  --text-2: oklch(0.700 0.008 var(--hue));
  --text-3: oklch(0.520 0.008 var(--hue));

  --accent:        oklch(0.82 0.145 75);
  --accent-hover:  oklch(0.88 0.155 78);
  --accent-quiet:  oklch(0.82 0.145 75 / 0.14);

  --font-display: 'Archivo', system-ui, sans-serif;
  --font-body:    'Archivo', system-ui, sans-serif;
  --font-mono:    'JetBrains Mono', ui-monospace, monospace;

  /* 4px spacing scale */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px; --s7: 48px; --s8: 72px;
  --s9: 112px;

  --radius:     4px;
  --radius-lg:  6px;

  --spring-smooth: cubic-bezier(0.22, 1, 0.36, 1);
  --spring-snappy: cubic-bezier(0.16, 1, 0.3, 1);

  --measure: 62ch;
  --page-max: 1180px;
  --gutter: clamp(20px, 5vw, 56px);
}

/* ============================================================
   2. RESET + BASE
   ============================================================ */
*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  color-scheme: dark;
}

body {
  margin: 0;
  background: var(--bg-page);
  color: var(--text-1);
  font-family: var(--font-body);
  font-size: 1rem;
  font-weight: 380;
  line-height: 1.6;
  font-variation-settings: 'wdth' 100;
  overflow-x: hidden;
}

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

a { color: inherit; text-decoration: none; }

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

::selection { background: var(--accent); color: oklch(0.145 0.008 62); }

/* Cross-document view transitions. Real page loads that animate.
   Chrome, Edge, Safari 18+. Firefox falls back to an instant nav.

   The outbound and inbound paths are mirrored: content leaves upward and
   arrives from below, so the two halves read as one continuous move rather
   than two unrelated fades. */
@view-transition { navigation: auto; }

::view-transition-old(root) { animation: vt-out 140ms ease-in both; }
::view-transition-new(root) { animation: vt-in 280ms var(--spring-smooth) both; }
@keyframes vt-out { to { opacity: 0; transform: translateY(-4px); } }
@keyframes vt-in  { from { opacity: 0; transform: translateY(8px); } }

/* ============================================================
   3. TYPOGRAPHY
   Display face is Archivo at expanded width + heavy weight.
   Only h1/h2 get the expanded treatment; smaller headings stay
   at normal width so they don't look broken.
   ============================================================ */
h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); line-height: 1.05; }

h1 {
  font-size: clamp(2.75rem, 7vw, 5.5rem);
  font-weight: 800;
  font-variation-settings: 'wdth' 112;
  letter-spacing: -0.025em;
  line-height: 1.02;
  text-wrap: balance;
}

h2 {
  font-size: clamp(1.75rem, 3.6vw, 3rem);
  font-weight: 700;
  font-variation-settings: 'wdth' 108;
  letter-spacing: -0.02em;
  line-height: 1.08;
  text-wrap: balance;
}

h3 {
  font-size: 1.125rem;
  font-weight: 600;
  font-variation-settings: 'wdth' 100;
  letter-spacing: -0.005em;
  line-height: 1.3;
}

p { margin: 0 0 var(--s4); max-width: var(--measure); color: var(--text-2); }
p:last-child { margin-bottom: 0; }

.lead {
  font-size: clamp(1.0625rem, 1.6vw, 1.25rem);
  color: var(--text-2);
  line-height: 1.55;
  max-width: 54ch;
}

/* Small uppercase label. Letter-spacing and case do the work.
   No dash, no line, no pill, no dot in front of it. */
.label {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  margin: 0 0 var(--s4);
}

.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums lining-nums; }

/* Content Jack still has to supply. Deliberately visible so nothing
   ships half-filled. Grep for "fill" before going live. */
.fill {
  font-family: var(--font-mono);
  font-size: 0.8125em;
  color: var(--accent);
  background: var(--accent-quiet);
  border: 1px dashed oklch(0.82 0.145 75 / 0.4);
  border-radius: 3px;
  padding: 1px 7px;
  white-space: nowrap;
}

/* ============================================================
   4. LAYOUT PRIMITIVES
   ============================================================ */
.wrap {
  width: 100%;
  max-width: var(--page-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

section { padding-block: clamp(56px, 9vw, 112px); }
section + section { padding-top: 0; }

.rule { height: 1px; background: var(--border); border: 0; margin: 0; }

/* ============================================================
   5. HEADER / NAV
   Always visible, blurred, never hides on scroll.
   3-column grid so the links are centred on the PAGE, not in the
   leftover flex space between the wordmark and the email link.
   ============================================================ */
.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  background: oklch(0.145 0.008 62 / 0.72);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border-bottom: 1px solid var(--border);
}

.site-header .wrap {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  height: 60px;
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  font-variation-settings: 'wdth' 108;
  font-size: 0.8125rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-1);
  justify-self: start;
}

.nav {
  display: flex;
  gap: var(--s5);
  justify-self: center;
}

.nav a {
  font-size: 0.875rem;
  font-weight: 450;
  color: var(--text-2);
  padding-block: var(--s2);
  position: relative;
  transition: color 120ms ease-out;
}
.nav a:hover { color: var(--text-1); }

.nav a[aria-current="page"] { color: var(--text-1); }
.nav a[aria-current="page"]::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--accent);
}

.header-contact {
  justify-self: end;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  color: var(--text-2);
  transition: color 120ms ease-out;
}
.header-contact:hover { color: var(--accent); }

/* Mobile: the centre nav becomes a scrollable row under the wordmark. */
@media (max-width: 720px) {
  .site-header .wrap {
    grid-template-columns: 1fr auto;
    height: auto;
    padding-block: var(--s3);
    row-gap: var(--s3);
  }
  .nav {
    grid-column: 1 / -1;
    justify-self: stretch;
    gap: 14px;
    overflow-x: auto;
    scrollbar-width: none;
    padding-bottom: 2px;
    /* Five items do not quite fit at 375px. The row scrolls, but a word
       chopped at the viewport edge reads as broken rather than scrollable,
       so the last few pixels fade out to say "there is more this way". */
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent 100%);
            mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent 100%);
  }
  .nav::-webkit-scrollbar { display: none; }
  .nav a { font-size: 0.75rem; white-space: nowrap; }

  /* Once everything fits there is nothing to scroll, so drop the fade. */
  @media (min-width: 400px) {
    .nav { -webkit-mask-image: none; mask-image: none; }
  }

  /* The email link would claim a third row to itself, pushing the fixed
     header past 200px on a phone. The address appears in the contact
     block and again in the footer, so nothing is lost by dropping it. */
  .header-contact { display: none; }
}

/* ============================================================
   6. HERO
   Full-bleed photograph, dark scrim, name, one line.
   The cropped "30" is his jersey number and his handle everywhere
   online. It is the one piece of decoration on the page and it
   names a concrete thing.
   ============================================================ */
.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  align-items: flex-end;
  padding-block: var(--s9) var(--s7);
  overflow: hidden;
  isolation: isolate;
  background: var(--bg-card); /* fallback tone while the photo loads */
}

.hero-media {
  position: absolute;
  inset: 0;
  z-index: -2;
}
.hero-media img {
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: center 35%;
  filter: saturate(0.75) contrast(1.05);
}

/* Scrim: dark at the bottom where the text sits, clearer up top. */
.hero-media::after {
  content: '';
  position: absolute; inset: 0;
  /* Heavy through the lower half, because that is where the label and
     the headline sit and the photograph behind them can be very bright. */
  background:
    linear-gradient(to top,
      oklch(0.145 0.008 62 / 0.97) 0%,
      oklch(0.145 0.008 62 / 0.90) 32%,
      oklch(0.145 0.008 62 / 0.58) 62%,
      oklch(0.145 0.008 62 / 0.60) 100%);
}

/* Film grain. Keeps the large dark gradient from banding. */
.grain::before {
  content: '';
  position: absolute; inset: 0; z-index: -1;
  background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  mix-blend-mode: soft-light;
  opacity: 0.06;
  pointer-events: none;
}

.hero-30 {
  position: absolute;
  right: var(--gutter);
  bottom: 12%;
  z-index: -1;
  font-family: var(--font-display);
  font-size: clamp(6rem, 15vw, 13rem);
  font-weight: 900;
  font-variation-settings: 'wdth' 118;
  line-height: 0.8;
  letter-spacing: -0.04em;
  color: transparent;
  -webkit-text-stroke: 1.5px oklch(0.82 0.145 75 / 0.42);
  user-select: none;
  pointer-events: none;
}

/* Below the stat strip breakpoint the number would collide with the copy,
   so it moves up into the photograph. The offset clears the taller
   two row header rather than sliding underneath it. */
@media (max-width: 860px) {
  .hero-30 {
    bottom: auto;
    top: 132px;
    font-size: clamp(5rem, 20vw, 9rem);
  }
}

.hero-inner { position: relative; width: 100%; }

/* The section label is dim by default, which is right on a flat page and
   wrong over a photograph. Here it has to hold up against whatever the
   image is doing behind it. */
.hero .label { color: oklch(0.90 0.012 var(--hue)); }

.hero h1 { margin-bottom: var(--s4); }

.hero-line {
  font-size: clamp(1.0625rem, 1.9vw, 1.375rem);
  color: var(--text-1);
  font-weight: 400;
  max-width: 46ch;
  margin-bottom: var(--s6);
}

.hero-line b { font-weight: 600; color: var(--text-1); }

/* ============================================================
   7. STAT STRIP
   Four facts, monospace, tabular. The row a coach scans first.
   ============================================================ */
.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 1px;
  background: var(--border);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.stat {
  background: oklch(0.145 0.008 62 / 0.6);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  padding: var(--s4) var(--s4) var(--s3);
}

.stat dt {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--text-3);
  margin-bottom: var(--s2);
}

.stat dd {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.0625rem;
  font-variation-settings: 'wdth' 104;
  color: var(--text-1);
  letter-spacing: -0.01em;
}

/* ============================================================
   8. DOOR CARDS (home)
   Asymmetric on purpose. Lacrosse is the wide one because it is
   why a coach opened the page.
   ============================================================ */
.doors {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--s3);
}

.door {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: 260px;
  padding: var(--s5);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  isolation: isolate;
  background: var(--bg-card);
  transition:
    border-color 200ms var(--spring-snappy),
    transform 260ms var(--spring-smooth);
}

.door--wide { grid-column: 1 / -1; min-height: 340px; }

.door img {
  position: absolute; inset: 0; z-index: -2;
  width: 100%; height: 100%;
  object-fit: cover;
  filter: saturate(0.7) contrast(1.02);
  transform: scale(1.02);
  transition: transform 700ms var(--spring-smooth), filter 400ms ease-out;
}

.door::after {
  content: '';
  position: absolute; inset: 0; z-index: -1;
  /* Cards get taller on a phone, so the copy reaches further up into the
     photograph than it does on a wide screen. The scrim has to cover that
     whole range, not just the bottom strip. */
  background: linear-gradient(to top,
    oklch(0.145 0.008 62 / 0.95) 12%,
    oklch(0.145 0.008 62 / 0.74) 52%,
    oklch(0.145 0.008 62 / 0.34) 100%);
  transition: opacity 300ms ease-out;
}

.door:hover { border-color: var(--border-strong); transform: translateY(-2px); }
.door:hover img { transform: scale(1.06); filter: saturate(0.92) contrast(1.04); }

.door h3 {
  font-size: clamp(1.375rem, 2.6vw, 2rem);
  font-weight: 700;
  font-variation-settings: 'wdth' 108;
  letter-spacing: -0.02em;
  margin-bottom: var(--s2);
}

.door p { color: var(--text-2); font-size: 0.9375rem; margin: 0; max-width: 42ch; }

.door-go {
  margin-top: var(--s4);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
}

@media (max-width: 720px) {
  .doors { grid-template-columns: 1fr; }
  .door, .door--wide { min-height: 220px; }
}

/* ============================================================
   9. SECTION HEADINGS
   ============================================================ */
.section-head { margin-bottom: var(--s6); }
.section-head h2 { margin-bottom: var(--s3); }

.page-head { padding-top: clamp(104px, 14vw, 168px); padding-bottom: var(--s7); }
.page-head h1 { margin-bottom: var(--s4); }

/* ============================================================
   10. MEDIA PANELS
   Video clips are muted MP4s that autoplay only while on screen.
   Muted is a design decision (nobody wants sound) and it also
   keeps licensed music off the domain.
   ============================================================ */
.media-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--s3);
}

.clip {
  position: relative;
  display: block;
  aspect-ratio: 4 / 5;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--bg-card);
  transition: border-color 200ms var(--spring-snappy), transform 260ms var(--spring-smooth);
}
.clip:hover { border-color: var(--border-strong); transform: translateY(-2px); }

.clip img, .clip video {
  width: 100%; height: 100%;
  object-fit: cover;
  filter: saturate(0.85);
}

.clip figcaption {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: var(--s6) var(--s4) var(--s3);
  /* Deep enough to stay readable over bright snow, which is most of
     what ends up on this page. */
  background: linear-gradient(to top,
    oklch(0.145 0.008 62 / 0.96) 0%,
    oklch(0.145 0.008 62 / 0.72) 45%,
    transparent 100%);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-2);
}

/* Film: click-to-load YouTube. The iframe and its ~1MB of scripts
   are only injected when someone actually presses play. */
.film {
  position: relative;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--bg-card);
  width: 100%;
  padding: 0;
  cursor: pointer;
  display: block;
  color: inherit;
  font: inherit;
}
.film img { width: 100%; height: 100%; object-fit: cover; }
.film iframe { width: 100%; height: 100%; border: 0; }

.film-play {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: oklch(0.145 0.008 62 / 0.35);
  transition: background 200ms ease-out;
}
.film:hover .film-play { background: oklch(0.145 0.008 62 / 0.18); }

.film-play span {
  display: grid;
  place-items: center;
  width: 72px; height: 72px;
  border-radius: 50%;
  background: oklch(0.145 0.008 62 / 0.72);
  border: 1px solid var(--border-strong);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  transition: transform 200ms var(--spring-snappy), background 200ms ease-out;
}
.film:hover .film-play span { transform: scale(1.06); background: oklch(0.145 0.008 62 / 0.86); }
.film-play svg { width: 22px; height: 22px; fill: var(--text-1); margin-left: 3px; }

/* Slot shown when the film URL has not been added yet. */
.slot {
  border: 1px dashed oklch(0.82 0.145 75 / 0.35);
  border-radius: var(--radius-lg);
  background: var(--bg-card);
  display: grid;
  place-items: center;
  text-align: center;
  padding: var(--s7) var(--s4);
  gap: var(--s2);
}
.slot .label { margin: 0; color: var(--accent); }
.slot p { color: var(--text-3); font-size: 0.875rem; margin: 0; }

/* ============================================================
   11. DATA LIST: measurables, academics, teams
   Monospace values, right-aligned, tabular so digits line up.
   ============================================================ */
.data {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

.data div {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--s4);
  padding: var(--s3) var(--s4);
  border-bottom: 1px solid var(--border);
  transition: background 120ms ease-out;
}
.data div:last-child { border-bottom: 0; }
.data div:hover { background: var(--bg-card); }

.data dt {
  font-size: 0.875rem;
  color: var(--text-2);
  font-weight: 400;
}

.data dd {
  margin: 0;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums lining-nums;
  font-size: 0.875rem;
  color: var(--text-1);
  text-align: right;
}

.two-col {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: var(--s5);
  align-items: start;
}

/* ============================================================
   12. PROJECT LIST (building page)
   Type-driven. No screenshots, no mockups, no device frames.
   ============================================================ */
.projects { border-top: 1px solid var(--border); }

.project {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: var(--s4) var(--s5);
  align-items: start;
  padding: var(--s5) 0;
  border-bottom: 1px solid var(--border);
  /* transform, not padding. Animating padding relayouts the row on every
     frame; a translate stays on the compositor. */
  transition: transform 240ms var(--spring-smooth);
}
.project:hover { transform: translateX(12px); }

.project-title {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-bottom: var(--s2);
}

.project h3 {
  font-size: 1.375rem;
  font-weight: 700;
  font-variation-settings: 'wdth' 106;
  letter-spacing: -0.015em;
}

.project p { font-size: 0.9375rem; margin: 0; max-width: 58ch; }

.status {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  padding: 3px 8px;
  border-radius: 3px;
  border: 1px solid var(--border);
  color: var(--text-3);
  white-space: nowrap;
}
/* "Live" reads as shipped/success, so it gets a real green rather than
   the site's warm gold -- gold stays reserved for CTAs and highlights. */
.status--live { color: oklch(0.78 0.15 145); border-color: oklch(0.78 0.15 145 / 0.4); background: oklch(0.78 0.15 145 / 0.14); }

/* One-off team-color badge for 2Way Lacrosse (their actual Carolina-blue
   branding, not the site's warm gold accent) and a legible alternative
   for "Private" -- the plain .status text-3 default reads as too dark. */
.status--carolina { font-size: 0.75rem; padding: 4px 10px; color: oklch(0.78 0.09 232); border-color: oklch(0.78 0.09 232 / 0.4); background: oklch(0.78 0.09 232 / 0.14); }
.status--private   { font-size: 0.75rem; padding: 4px 10px; color: oklch(0.82 0.06 340); border-color: oklch(0.82 0.06 340 / 0.4); background: oklch(0.82 0.06 340 / 0.14); }

.project-meta {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  color: var(--text-3);
  text-align: right;
  white-space: nowrap;
}

@media (max-width: 720px) {
  .project { grid-template-columns: 1fr; }
  .project-meta { text-align: left; }
}

/* ============================================================
   13. GALLERY (photography)
   CSS columns give varied heights without cropping every frame
   to the same rectangle.
   ============================================================ */
.gallery {
  columns: 3;
  column-gap: var(--s3);
}
@media (max-width: 900px) { .gallery { columns: 2; } }
@media (max-width: 560px) { .gallery { columns: 1; } }

.gallery figure {
  margin: 0 0 var(--s3);
  break-inside: avoid;
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--bg-card);
  position: relative;
}

.gallery img {
  width: 100%;
  height: auto;
  filter: saturate(0.88);
  transition: transform 600ms var(--spring-smooth), filter 400ms ease-out;
}
.gallery figure:hover img { transform: scale(1.03); filter: saturate(1); }

/* ============================================================
   14b. VAULT GRAPH + FLOW DIAGRAM (AI page)
   The graph is a generated illustration, not a live export of the real
   vault -- see the caption on the page. Nodes gently pulse so it reads as
   alive rather than a static screenshot; reduced-motion users get the
   global animation-duration override at the bottom of this file.
   ============================================================ */
.graph-frame {
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--bg-card);
  padding: var(--s5);
}

.vault-graph { width: 100%; height: auto; display: block; }

.vg-edges line { stroke: var(--border-strong); stroke-width: 1; }

.vg-node {
  transform-origin: center;
  transform-box: fill-box;
  animation-name: vg-pulse;
  animation-timing-function: ease-in-out;
  animation-iteration-count: infinite;
}
.vg-node--hub  { fill: var(--accent); }
.vg-node--leaf { fill: var(--text-2); }
.vg-node--mini { fill: var(--text-2); }
.vg-node--lone { fill: var(--text-3); }

@keyframes vg-pulse {
  0%, 100% { transform: scale(1);     opacity: 0.85; }
  50%      { transform: scale(1.22);  opacity: 1; }
}

.graph-caption {
  margin-top: var(--s4);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.05em;
  color: var(--text-3);
}

/* Simple left-to-right pipeline: JARVIS's wake -> think -> speak loop. */
.flow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2);
  margin: var(--s5) 0 var(--s6);
}

.flow-step {
  font-family: var(--font-mono);
  font-size: 0.75rem;
  letter-spacing: 0.02em;
  color: var(--text-1);
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--s2) var(--s3);
  white-space: nowrap;
}

.flow-arrow {
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 0.875rem;
}

/* ============================================================
   14. FOOTER
   ============================================================ */
.site-footer {
  border-top: 1px solid var(--border);
  padding-block: var(--s7) var(--s6);
  margin-top: var(--s7);
}

.footer-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: var(--s6) var(--s5);
  margin-bottom: var(--s7);
}

.footer-grid h4 {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3);
  margin-bottom: var(--s3);
}

.footer-grid ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s1); }

/* inline-block so the vertical padding actually counts toward the hit area.
   As plain inline links these were 16px tall, which is under the 24px
   minimum and miserable to hit with a thumb. */
.footer-grid a {
  display: inline-block;
  padding-block: 7px;
  font-size: 0.9375rem;
  color: var(--text-2);
  transition: color 120ms ease-out;
}
.footer-grid a:hover { color: var(--accent); }

.footer-base {
  display: flex;
  justify-content: space-between;
  gap: var(--s4);
  flex-wrap: wrap;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.06em;
  color: var(--text-3);
}

/* Inline text link, used inside prose */
.link {
  color: var(--text-1);
  border-bottom: 1px solid oklch(0.82 0.145 75 / 0.45);
  transition: border-color 140ms ease-out, color 140ms ease-out;
}
.link:hover { color: var(--accent); border-bottom-color: var(--accent); }

/* Official-style "Download on the App Store" badge -- black surface so it
   reads as the real Apple mark regardless of the page's own palette. */
.store-badge {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 9px 16px 9px 14px;
  margin-top: var(--s3);
  border-radius: 10px;
  background: oklch(0.12 0 0);
  border: 1px solid oklch(1 0 0 / 0.14);
  text-decoration: none;
  transition: transform 220ms var(--spring-smooth), border-color 200ms ease-out, background 200ms ease-out;
}
.store-badge:hover { transform: translateY(-2px); border-color: oklch(1 0 0 / 0.28); background: oklch(0.16 0 0); }
.store-badge:active { transform: translateY(0) scale(0.97); }
.store-badge-icon { width: 22px; height: 22px; fill: oklch(0.98 0 0); flex-shrink: 0; }
.store-badge-text { display: flex; flex-direction: column; line-height: 1.05; }
.store-badge-eyebrow { font-family: var(--font-body); font-size: 0.6875rem; color: oklch(0.98 0 0 / 0.75); }
.store-badge-title { font-family: var(--font-display); font-size: 1.0625rem; font-weight: 600; letter-spacing: -0.01em; color: oklch(0.98 0 0); }

/* Button */
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  height: 40px;
  padding-inline: var(--s4);
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text-1);
  font-family: var(--font-body);
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
  transition: background 140ms ease-out, border-color 140ms ease-out, transform 140ms var(--spring-snappy);
}
.btn:hover { background: var(--bg-elevated); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }

.btn--accent {
  background: var(--accent);
  border-color: var(--accent);
  color: oklch(0.145 0.008 62);
  font-weight: 600;
}
.btn--accent:hover { background: var(--accent-hover); border-color: var(--accent-hover); }

.btn-row { display: flex; gap: var(--s3); flex-wrap: wrap; }

/* ============================================================
   15. MOTION
   Scroll reveals are added by site.js, which sets data-reveal.
   Anything that has not been observed yet stays visible if JS
   fails, so the page never depends on script to be readable.
   ============================================================ */
/* ------------------------------------------------------------
   MASKED HEADLINES
   site.js wraps each [data-mask] headline's text in .mask-inner so it
   can slide up from behind its own box.

   The padding/negative-margin pair matters: "Photography" and
   "Coaching" have descenders, and overflow:hidden on a 1.02
   line-height would slice the tails off the g, p and y.
   ------------------------------------------------------------ */
.anime [data-mask] {
  display: block;
  overflow: hidden;
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}
.mask-inner { display: block; will-change: transform; }

/* Anime.js writes these as inline styles, so the CSS transition would
   only fight it. The fallback path keeps its transition below. */
.anime [data-reveal] { transition: none; }
.anime .hero-media img,
.anime .hero-30 { will-change: transform; }

/* Hero copy starts hidden only when a script is present to reveal it.
   Both the anime path and the fallback clear this. */
.js [data-hero] { opacity: 0; }
.js [data-hero].is-in { opacity: 1; }

/* Scoped to .js (set by an inline script in <head>) so that if the
   script never runs, every reveal element is simply visible. */
.js [data-reveal] {
  opacity: 0;
  transform: translateY(12px);
  transition:
    opacity 520ms var(--spring-smooth),
    transform 520ms var(--spring-smooth);
}
.js [data-reveal].is-in { opacity: 1; transform: none; }

/* Stagger, but only inside groups that come into view together.

   Applying this to every reveal was wrong: each element is observed
   individually, so a gallery figure scrolled into view on its own would
   still sit out its nth-child delay before starting. With thirteen figures
   in a gallery, everything from the seventh on waited 360ms after entering
   the viewport, which reads as lag rather than choreography.

   The doors and the stat strip do arrive as a set, so they keep it. */
.js .doors > [data-reveal]:nth-child(2) { transition-delay: 60ms; }
.js .doors > [data-reveal]:nth-child(3) { transition-delay: 120ms; }
.js .doors > [data-reveal]:nth-child(4) { transition-delay: 180ms; }
.js .doors > [data-reveal]:nth-child(5) { transition-delay: 240ms; }
.js .doors > [data-reveal]:nth-child(6) { transition-delay: 300ms; }

/* Press feedback lands on pointer-down, not on release. Waiting for the click
   to acknowledge a tap is the difference between an interface that answers
   and one that feels dead. */
.door:active, .clip:active, .gallery figure:active { transform: scale(0.994); }
.btn:active { transform: scale(0.97); }
.door, .clip, .gallery figure, .btn { -webkit-tap-highlight-color: transparent; }

/* Translucent chrome collapses to a solid surface when the reader has asked
   for less transparency, and gains a hard border when they have asked for
   more contrast. Both are separate signals from reduced motion. */
@media (prefers-reduced-transparency: reduce) {
  .site-header { background: oklch(0.145 0.008 62); backdrop-filter: none; -webkit-backdrop-filter: none; }
  .stat, .film-play span { backdrop-filter: none; -webkit-backdrop-filter: none; background: var(--bg-card); }
}

@media (prefers-contrast: more) {
  :root { --border: oklch(1 0 0 / 0.32); --border-strong: oklch(1 0 0 / 0.5); --text-2: oklch(0.84 0.008 62); --text-3: oklch(0.72 0.008 62); }
  .site-header { background: oklch(0.145 0.008 62); backdrop-filter: none; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .js [data-reveal] { opacity: 1; transform: none; }
  .js [data-hero] { opacity: 1; }
  .anime [data-mask] { overflow: visible; }
  @view-transition { navigation: none; }
}
