/* ==========================================================================
   Kyle Mills Hall — kylmls.com
   One stylesheet, no frameworks.

   The page is a single 600px column that scrolls. How much of it you see is
   driven by <body data-state="..."> which is one of: less | more.
   The JS only ever changes that attribute (plus the toggle knob) — every
   expand and collapse below is a CSS transition.

     less  headline + links + CV job titles + project images
     more  …plus bullets under each job, a title under each project, and
           the projects kept out of Less
   ========================================================================== */

/* ---------- Fonts (self-hosted, no Google CDN) ---------- */
@font-face {
  font-family: "JetBrains Mono";
  src: url("../assets/fonts/jetbrains-mono-v24-latin-regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Noto Serif";
  src: url("../assets/fonts/noto-serif-v33-latin-200.woff2") format("woff2");
  font-weight: 200; font-style: normal; font-display: swap;
}
/* Only used for the two emphasised bits in the intro ("0→1", "15+ years").
   Without a real medium face the browser would fake the weight by smearing
   the light one, which looks muddy at 18px. */
@font-face {
  font-family: "Noto Serif";
  src: url("../assets/fonts/noto-serif-v33-latin-500.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Inter";
  src: url("../assets/fonts/inter-v20-latin-regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Inter";
  src: url("../assets/fonts/inter-v20-latin-500.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Noto Sans";
  src: url("../assets/fonts/noto-sans-v42-latin-regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}

/* ---------- Design tokens ----------
   Everything that changes between light and dark lives in this block, and
   nowhere else. The rules further down only ever say var(--something), so
   the dark theme underneath is just this same list with new values. */
:root {
  /* keeps scrollbars and form controls (which we don't style) in step */
  color-scheme: light;

  /* Text */
  --bg: #fafcfc;
  --ink: #000;                 /* "Hi, I'm Kyle" */
  --ink-soft: #313131;         /* job titles, project titles */
  --prose: #484848;            /* the serif intro */
  --gray: #525252;             /* Less / More labels */
  --gray-soft: #717171;        /* project blurb */
  --gray-mute: #8a8a8a;        /* company, year, CV detail */
  --link: #000000;
  --accent-a: #ff3366;
  --accent-b: #ff3366;
  --rule: #a2a2a2;             /* the dotted dividers */
  --avatar-opacity: 1;         /* the head mark is knocked back in dark mode */

  /* Surfaces */
  --surface: #fff;                        /* cards + the case-study panel */
  --surface-sunken: #f6f6f6;              /* the plate a card image sits on */
  --surface-edge: inset 0 1px 0 0 #fff;   /* 1px lit top edge of a card */
  --plate-edge: rgba(0, 0, 0, .07);       /* edge of a white brand plate */
  --chip: rgba(0, 0, 0, .06);             /* the round close button */
  --chip-hover: rgba(0, 0, 0, .12);
  --backdrop: rgba(250, 252, 252, .72);   /* frosted layer behind a case study */
  --card-shadow: 0 1px 4px 0 rgba(0, 0, 0, .15), 0 4px 12px 0 rgba(0, 0, 0, .08);
  --panel-shadow: 0 2px 8px rgba(0, 0, 0, .12), 0 24px 64px rgba(0, 0, 0, .18);
  --store-btn: #111;                      /* App Store button, and its text */
  --store-btn-ink: #fff;

  /* Toggle */
  --track: #373737;
  --track-shadow: 0 1px 0 0 #fff,
                  inset 0 .5px 0 0 rgba(0, 0, 0, .12),
                  inset 0 2px 4px 0 rgba(0, 0, 0, .06);
  --dot: #cacaca;
  --knob: #fff;
  --knob-ring: #ececec;
  --knob-shadow: 0 1px 2px rgba(0, 0, 0, .28), 0 2px 6px rgba(0, 0, 0, .14);

  --col: 600px;                        /* the one column everything sits in */
  --edge: clamp(24px, 5vw, 64px);      /* left/right page margin */
  --section-end:   clamp(28px, 5vw, 50px); /* air under a section, before its divider */
  --section-start: clamp(4px, 1vw, 10px);  /* the shorter drop from a divider to what follows */
  --card-hero: 350px;                  /* height of a project card's image */
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace;
  --font-serif: "Noto Serif", Georgia, serif;
  --font-sans: "Inter", system-ui, sans-serif;
  --font-sans-2: "Noto Sans", system-ui, sans-serif;
  --ease-out: cubic-bezier(.22, 1, .36, 1);          /* smooth glide */
  --ease-spring: cubic-bezier(.34, 1.45, .45, 1);    /* slight overshoot = bounce */
}

/* ---------- Dark mode ----------
   Same tokens, warm near-black values from the Figma "Dark mode" frames —
   the palette is a very dark plum rather than grey, so the pink logo belongs.

   data-theme is set on <html> by the small script in index.html, before the
   page paints. It starts from the OS setting (System Settings → Appearance
   on a Mac) and clicking the head mark overrides it. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --bg: #1a0d10;
  /* Text is lifted from the Figma values to pass WCAG AA (4.5:1). Each one
     is measured against --surface, the lighter of the two grounds text sits
     on, so it passes on the page too. Three even steps, brightest first,
     same order as light mode: ink 7.6:1 · ink-soft/prose 6:1 · grays 4.7:1 */
  --ink: #bcabaf;
  --ink-soft: #ac969c;
  --prose: #ac969c;
  --gray: #9d8288;
  --gray-soft: #9d8288;
  --gray-mute: #9d8288;
  --link: #d0919e;
  --rule: #4d3238;
  /* knocked back a little, since full-strength pink is loud on near-black —
     but it's also the theme button, and a button has to reach 3:1 against
     the page. .85 is the lowest that gets the purple end of it there. */
  --avatar-opacity: .85;

  --surface: #2a1a1e;
  --surface-sunken: #190b0e;
  --surface-edge: inset 0 1px 0 0 #4d3238;
  --plate-edge: #4d3238;
  --chip: rgba(255, 255, 255, .08);
  --chip-hover: rgba(255, 255, 255, .14);
  --backdrop: rgba(26, 13, 16, .72);
  /* shadows have to go much deeper on a dark page — a 15%-black shadow
     is invisible against a near-black background */
  --card-shadow: 0 1px 4px 0 rgba(0, 0, 0, .6), 0 4px 12px 0 rgba(0, 0, 0, .4);
  --panel-shadow: 0 2px 8px rgba(0, 0, 0, .5), 0 24px 64px rgba(0, 0, 0, .45);
  /* flips to white, the way it does on the Shutterfoot site's own dark theme */
  --store-btn: #fff;
  --store-btn-ink: #000;

  --track: #25171a;
  --track-shadow: 0 1px 0 0 #513038,
                  inset 0 .5px 0 0 #3b282c,
                  inset 0 2px 4px 0 rgba(24, 11, 14, .83);
  --dot: #473337;
  /* the knob's position IS the setting, so it needs 3:1 against the track */
  --knob: #8b5b65;
  --knob-ring: rgba(255, 255, 255, .08);
  --knob-shadow: 0 .5px 0 0 rgba(0, 0, 0, .3),
                 0 1px 3px 0 rgba(0, 0, 0, .12),
                 inset 0 1px 0 0 rgba(255, 255, 255, .1);
}

/* ---------- Reset-ish ---------- */
* { margin: 0; padding: 0; box-sizing: border-box; }
html { min-height: 100%; }
body {
  min-height: 100%;
  background: var(--bg);
  font-family: var(--font-sans);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
}
img { display: block; max-width: 100%; }
button { font: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; }
ul { list-style: none; }

/* ---------- Theme fade ----------
   Colours ease from one theme to the other instead of snapping. This is
   cosmetic only: the theme flips instantly on <html>, so if a transition
   never runs the page is still in the right state.

   `transition` is a shorthand, so this rule is replaced outright on any
   element that declares its own — and being a universal selector, it loses
   every one of those contests. That's deliberate: it can't damage the
   animations already in the file. The handful of rules that do transition
   something else list these colour properties themselves, so they fade too
   rather than snapping while the rest of the page eases. */
*, *::before, *::after {
  transition: background-color .3s ease, color .3s ease, box-shadow .3s ease;
}

/* Simple fades used when the page first loads */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes rise-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* ==========================================================================
   Header — just the head mark, pinned to the top of the window
   ========================================================================== */
.site-header {
  /* fixed, not absolute: the column scrolls now, and an absolute header
     would slide away with it, taking the light/dark switch with it */
  position: fixed; inset: 0 0 auto 0; height: 148px; z-index: 10;
  /* Three columns with matching outer thirds, so the head mark sits dead
     centre no matter how long the name or the tagline are, and they hang
     off either side of it. align-content: start puts the row at the top of
     the padding, so padding-top IS the head mark's distance from the top. */
  display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; align-content: start;
  gap: 12px; padding-top: 18px;
  animation: fade-in .6s ease backwards;
  /* --away is set by js/main.js, 0 → 1: 0 is home, 1 is clear of the top
     of the window. It follows the scroll pixel for pixel — away as the
     work scrolls up, back the moment you scroll down. There's deliberately
     no transition on it: a transition would ease toward each new value
     over its own duration, which lags a finger or a trackpad by a few
     frames and feels like the header is being dragged along behind the
     page. Reading the value raw is what makes it track 1:1.
     The fade that goes with the lift is NOT set here — it's on
     `.site-header > *` below, and only there. Fading the scrim too, by
     opacity or by mask, cross-fades the blurred page with the sharp one,
     and even a 20% share of crisp text edges reads as "no blur" for the
     whole slide. So the scrim slides out at full strength instead; its
     soft bottom edge is the last thing to leave, so nothing pops. */
  transform: translateY(calc(var(--away, 0) * -100%));
  /* the strip mustn't swallow clicks meant for the page under it;
     only the head itself is interactive */
  pointer-events: none;
}
/* Frosted scrim. Three things are happening:
   1. the tint is a flat 88% of the page colour, never fully opaque, so
      content stays *just* visible as it slides under the head mark;
   2. the blur smears whatever is under there into texture, so it reads as
      depth instead of legible text peeking through;
   3. the mask fades the tint and the blur out TOGETHER — backdrop-filter
      always applies to the whole box, so without a mask the blur stops in a
      dead straight line at the bottom edge. The mask's stops trace an ease
      curve; a plain two-stop ramp bands.
   It lives on a pseudo-element, not on .site-header itself, because a mask
   fades an element's CHILDREN too — on the header directly, tightening the
   ramp starts ghosting out the bottom of the head mark, which reads as a
   rendering bug. Masking only the scrim decouples the two, so the fade can
   be tuned as short as you like and the head mark stays crisp. */
.site-header::before {
  /* The scrim bleeds 2px past the window edges instead of stopping flush
     with them. A backdrop-filter that also carries a mask gets rasterised
     into its own layer, and on a fractional device pixel ratio — a scaled
     display, or any browser zoom off 100% — that layer's edge rounds
     inward, leaving a hairline of unblurred page at the very top with the
     column visibly scrolling through it. Pushing the edge outside the
     viewport puts the rounding somewhere nobody can see. Only `bottom`
     stays at 0, so the fade still ends exactly where it did. */
  content: ''; position: absolute; inset: -2px -2px 0 -2px;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  -webkit-backdrop-filter: blur(14px);
          backdrop-filter: blur(14px);
  /* The stop count is doing real work — don't collapse it to a midpoint
     hint. A hint interpolates as a power curve, which has a vertical or
     near-vertical tangent at one end; that kink in the alpha ramp shows
     up as a faint line. The blur makes any kink twice as obvious, since
     the blur fades with the mask and the eye reads a sudden change in
     blur strength far more readily than a change in tint. These stops
     trace an S-curve whose slope changes gently the whole way down. */
  -webkit-mask-image: linear-gradient(to bottom,
    #000 0%, #000 28%,
    rgba(0,0,0,.93) 40%, rgba(0,0,0,.80) 52%, rgba(0,0,0,.60) 64%,
    rgba(0,0,0,.38) 76%, rgba(0,0,0,.18) 86%, rgba(0,0,0,.05) 94%,
    transparent 100%);
          mask-image: linear-gradient(to bottom,
    #000 0%, #000 28%,
    rgba(0,0,0,.93) 40%, rgba(0,0,0,.80) 52%, rgba(0,0,0,.60) 64%,
    rgba(0,0,0,.38) 76%, rgba(0,0,0,.18) 86%, rgba(0,0,0,.05) 94%,
    transparent 100%);
}
.site-header > * {
  position: relative; z-index: 1;   /* above the scrim */
  /* The lift-away fade for the head mark, name and tagline (the scrim
     doesn't fade — see .site-header). It's a filter rather than `opacity`
     because the name and tagline already use opacity (with a transition)
     for their own arrival — filter multiplies with that instead of
     overwriting it, and having no transition it tracks the scroll 1:1. */
  filter: opacity(calc(1 - var(--away, 0)));
}
/* The head mark doubles as the light/dark switch */
.avatar-btn {
  pointer-events: auto;
  width: 32px; height: 32px;
  border-radius: 50%; line-height: 0;
}

/* ---------- Name + tagline: they arrive once the bio is gone ----------
   Nothing to do with the toggle — at every detail level, scrolling the
   intro up under the head mark brings these two out from behind it. */
.header-name {
  justify-self: end;
  font-family: var(--font-mono); font-size: 16px; white-space: nowrap;
}
.header-tagline {
  justify-self: start;
  font-family: var(--font-serif); font-weight: 200; font-size: 16px;
  color: var(--ink-soft); white-space: nowrap;
}
.header-name, .header-tagline {
  opacity: 0;
  transition: opacity .35s ease, transform .5s var(--ease-out), color .3s ease;
}
/* tucked in toward the head mark until they're called for */
.header-name    { transform: translateX(14px); }
.header-tagline { transform: translateX(-14px); }
body.past-bio .header-name,
body.past-bio .header-tagline { opacity: 1; transform: translateX(0); }

.avatar-btn:focus-visible { outline: 2px solid var(--accent-b); outline-offset: 4px; }
.avatar {
  width: 32px; height: 32px; object-fit: cover;
  opacity: var(--avatar-opacity);
  transition: opacity .25s ease, transform .3s var(--ease-spring);
}
/* comes up to full strength on hover, so it reads as something you can press */
.avatar-btn:hover .avatar { opacity: 1; transform: scale(1.09); }
.avatar-btn:active .avatar { transform: scale(.94); }

/* ==========================================================================
   The column — one centred stack, top to bottom
   ========================================================================== */
.column {
  /* the padding is outside the 600px, so the text column stays exactly 600 */
  max-width: calc(var(--col) + 2 * var(--edge));
  margin-inline: auto;
  padding: 122px var(--edge) 132px;   /* clears the head mark and the toggle */
  display: flex; flex-direction: column; gap: 22px;
}

/* ---------- Intro ---------- */
.hero {
  display: flex; flex-direction: column; gap: 22px;
  animation: fade-in .6s ease backwards;
}
.hero h1 {
  font-family: var(--font-mono); font-weight: 400;
  font-size: 24px; line-height: 1.3;
}
.lede {
  font-family: var(--font-serif); font-weight: 200;
  font-size: 20px; line-height: 1.45; color: var(--prose);
}
.lede strong { font-weight: 500; }
.lede p + p { margin-top: 1.35em; }

.hero-links {
  font-family: var(--font-mono); font-size: 16px; line-height: 32px;
  color: var(--link);
}
.hero-links a {
  color: inherit; text-decoration: underline;
  text-underline-position: from-font;
  transition: color .2s ease;
}
.hero-links a + a { margin-left: 20px; }
.hero-links a:hover { color: var(--accent-a); }

/* ---------- The dotted dividers ----------
   Figma draws these as a 1px line with a 2-on/4-off dash pattern. A CSS
   `dotted` border can't be tuned like that, so it's a repeating gradient:
   2px of ink, 4px of nothing, forever. */
.rule {
  border: 0; height: 1px; flex: 0 0 1px;
  background-image: repeating-linear-gradient(
    to right, var(--rule) 0 2px, transparent 2px 6px);
  /* Deliberately lopsided: the long space sits ABOVE the rule (it's the
     padding on the section that just ended, below) and only a short drop
     sits below it, so the rule reads as the heading of what comes next
     rather than a line floating between two blocks. The column's own 22px
     gap is on top of both numbers. */
  margin-block: 0 var(--section-start);
}

/* the two sections that are followed by a rule push it away from their
   own last line */
.hero, .cv { padding-bottom: var(--section-end); }

/* ==========================================================================
   CV — title row always on; bullets in "More"
   ==========================================================================
   Opening and closing, since it's the same trick in three places: a block
   that should animate to its own natural height can't use `height: auto`
   — browsers refuse to animate to it. So the block is a grid with ONE row
   sized in fr units: 0fr is closed, 1fr is "as tall as the content", and
   fr units DO animate. The child clips with overflow: hidden, so its text
   is trimmed away as the row closes.

   Two rules that both matter:

   • One collapsing row per grid. Put two in the same grid and the browser
     measures them together, so the open one reserves room for the closed
     one's content — a block that opens three lines tall but leaves a
     four-line hole under it. That's why the bullets get their own wrapper
     <div> in the HTML rather than being a row of the job itself. (Chrome
     happens to hide this; Safari doesn't.)

   • Nothing inside a collapsing row may carry top/bottom padding. A box
     with box-sizing: border-box can't shrink below its own padding, so
     16px of padding would leave a 32px stub behind. Use margins on the
     children instead — those get clipped. */
.cv { display: flex; flex-direction: column; gap: 12px; }

/* each job opens a beat after the one above it */
.cv-line:nth-child(1) { --stagger: 0s; }
.cv-line:nth-child(2) { --stagger: .04s; }
.cv-line:nth-child(3) { --stagger: .08s; }
.cv-line:nth-child(n+4) { --stagger: .12s; }

.cv-head {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0 12px;
  line-height: 32px;
}
.cv-role    { font-family: var(--font-sans-2); font-size: 16px; color: var(--ink-soft); }
.cv-company { font-family: var(--font-mono);   font-size: 16px; color: var(--gray-mute); }
.cv-year {
  font-family: var(--font-mono); font-size: 16px; color: var(--gray-mute);
  margin-left: auto;   /* pinned to the right edge of the column */
}

/* the bullets: one openable row */
.cv-detail {
  display: grid; grid-template-rows: 0fr;
  font-family: var(--font-sans-2); font-size: 16px; line-height: 32px;
  color: var(--gray-mute);
  opacity: 0;
  /* the text starts fading a beat after its row starts opening */
  transition: grid-template-rows .5s var(--ease-out) var(--stagger, 0s),
              opacity .25s ease calc(.1s + var(--stagger, 0s)),
              color .3s ease;
}
.cv-detail > * { overflow: hidden; min-height: 0; }

/* flex, so a bullet that wraps hangs its second line under the text
   rather than sliding back under the dot */
.cv-detail li { display: flex; gap: 8px; }
.cv-detail li::before { content: "\2022"; flex: none; }

body[data-state="more"] .cv-detail { grid-template-rows: 1fr; opacity: 1; }

/* ==========================================================================
   Project cards — image always on; the text block opens in More
   ========================================================================== */
.work {
  --work-gap: 60px;
  display: flex; flex-direction: column; gap: var(--work-gap);
}

.card {
  position: relative;
  display: grid;
  grid-template-rows: var(--card-hero) 0fr;   /* image · text */
  background: var(--surface); border-radius: 12px; overflow: hidden;
  box-shadow: var(--card-shadow);
  transition: grid-template-rows .5s var(--ease-out),
              background-color .3s ease, box-shadow .3s ease;
}
/* The lit 1px top edge. It has to be an overlay rather than an inset
   box-shadow on the card itself: an inset shadow paints under the card's
   children, and .card-hero is opaque and sits right at the top, so it would
   cover the edge completely. (Figma paints an inner shadow ON TOP of a
   frame's children, which is why it shows there and not here.) */
.card::after {
  content: ""; position: absolute; inset: 0;
  border-radius: inherit; pointer-events: none;
  box-shadow: var(--surface-edge);
}
body[data-state="more"] .card { grid-template-rows: var(--card-hero) 1fr; }

/* ---------- Cards that only show up in More ----------
   Add "show-in-more" to an <article class="card"> to keep it out of Less.

   A card arrives and leaves in two beats rather than popping:
     arriving — the cards below slide down to open the space, THEN it fades in
     leaving  — it fades out first, THEN the space closes back up
   The space is the card's own height plus the flex gap. Height is the hero
   row: a fixed length, so grid-template-rows interpolates (0fr wouldn't —
   the two track lists have to be the same shape to animate between). The gap
   can't be animated from here, so a negative bottom margin cancels it while
   the card is collapsed. That assumes the card has a sibling after it — a
   hidden card at the very end of the list would pull one gap too many. */
.show-in-more {
  /* arriving: the space opens, and the card fades in behind it */
  transition: grid-template-rows .5s var(--ease-out),
              margin-bottom .5s var(--ease-out),
              opacity .3s ease .3s,
              visibility 0s,
              background-color .3s ease, box-shadow .3s ease;
}
body[data-state="less"] .show-in-more {
  grid-template-rows: 0px 0fr;
  margin-bottom: calc(var(--work-gap) * -1);
  opacity: 0;
  pointer-events: none;
  /* visibility rather than display:none: display can't be transitioned, but
     visibility can be held until the end — so the card still animates out,
     then drops off the tab order and out of the screen reader for good. */
  visibility: hidden;
  /* leaving: fade out, then close the space (.18 + .45 = .63s, which is
     also when visibility flips) */
  transition: opacity .22s ease,
              grid-template-rows .45s var(--ease-out) .18s,
              margin-bottom .45s var(--ease-out) .18s,
              visibility 0s linear .63s;
}

.card-hero {
  background: var(--surface-sunken);
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
}
.card-hero img,
.card-hero video {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform .5s var(--ease-out);
}
/* For artwork on a transparent background (floating UI screens, not a
   photo): cover would crop it to fill the box, so the whole image shows
   instead, inset from the edges on the sunken plate. border-box keeps the
   padding inside the 100% size rather than adding to it. */
.card-hero-contain img {
  object-fit: contain;
  padding: 0px; box-sizing: border-box;
}
/* Same, for artwork flattened onto white (a JPG can't be transparent): the
   plate turns white to match, so the image's edges disappear instead of
   showing as a white box on grey. White in dark mode too, like the photo
   cards that already fill their box with white. */
.card-hero-white { background: #fff; }
/* And black, for artwork drawn in white on transparent (a logo). Black in
   light mode too — it's the artwork's own ground, not a theme colour. */
.card-hero-black { background: #000; }
/* A wide logo lockup, inset well in from every edge so it sits in the card
   like a badge rather than running wall to wall. Percent padding is a share
   of the card's width, so the margin tightens with the card on a phone
   instead of squeezing the logo. Pair with .card-hero-contain. */
.card-hero-logo img { padding: 16%; }

/* A card has no button on it in any state — the whole card opens the case
   study. Nothing on the page announces it — the cursor and a slow push-in
   on the image are the only tells, which is the point. */
.card { cursor: pointer; }
.card:hover .card-hero img,
.card:hover .card-hero video { transform: scale(1.015); }
.card:focus-visible {
  outline: 2px solid var(--accent-b); outline-offset: 3px;
}
/* Closing the case study hands focus back to the card via script, so the
   browser can't always tell that came from a mouse click rather than a
   keyboard press. main.js adds this class for pointer-opened cards so the
   keyboard-only ring doesn't flash up after a mouse close. */
.card.no-focus-ring:focus-visible { outline: none; }
/* Clicking a card hides it and hands off to the flying copy — which also
   drops :hover, so the push-in would play itself back out underneath the
   flight. Invisible, but still costing frames. */
body.detail-open .card-hero img,
body.detail-open .card-hero video { transition: none; }

/* Side padding is safe here; top and bottom would stop the row collapsing
   (see the note above the CV), so the children carry those as margins. */
.card-text {
  overflow: hidden; min-height: 0;
  padding-inline: 16px;
  opacity: 0;
  transition: opacity .25s ease;
}
body[data-state="more"] .card-text { opacity: 1; transition-delay: .12s; }

/* margins rather than padding on .card-text, so the row can still close
   to nothing — see the note above the CV */
.card-head { margin: 16px 0; }
.card-title {
  font-family: var(--font-sans-2); font-weight: 400;
  font-size: 16px; line-height: 22px; color: var(--ink-soft);
}
/* ==========================================================================
   Case-study overlay — opens from a card like it's the card's back side
   ========================================================================== */
body.detail-open { overflow: hidden; }
.detail-layer { position: fixed; inset: 0; z-index: 50; perspective: 1600px; }
.detail-backdrop {
  position: absolute; inset: 0;
  background: var(--backdrop);
}
/* The blur waits until the card has landed. Blurring the whole window is
   one of the most expensive things a browser can be asked to do, and doing
   it on every frame — while a 3D rotation and a resize are already running
   — is what makes a flip stutter. Behind a 72% tint you can barely tell
   it isn't there yet.

   It then ramps in rather than snapping: the transition lives on this rule
   only, so it applies when .flying comes OFF (the landing) and is simply
   absent while it's on. That means the ramp costs frames only after the
   flip is done, and on close the blur drops instantly instead of animating
   underneath the flight back. */
.detail-layer:not(.flying) .detail-backdrop {
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  transition: -webkit-backdrop-filter 1.5s var(--ease-out),
                      backdrop-filter 1.5s var(--ease-out);
}
/* --detail-gap is the panel's one spacing unit: the space between images,
   the padding around them, and the close button's inset — so the button's
   edge lines up with the images' edge. It lives here, not on .detail-body,
   because the button sits outside the body. --close is the button's size,
   which the title also needs to know to keep clear of it. */
.detail-panel {
  --detail-gap: 18px;
  --close: 34px;
  position: absolute; inset-block: max(18px, 2vh); inset-inline: 0;
  margin-inline: auto; width: min(880px, 90vw);
  background: var(--surface); border-radius: 16px; overflow: hidden;
  box-shadow: var(--panel-shadow);
  display: flex; flex-direction: column;
}
/* A rounded square with squircle corners — the curve eases into the straight
   edge instead of meeting it at a hard join, like an iOS app icon. So it
   hugs the corner more tightly than a plain radius of the same size would.
   corner-shape is Chrome/Edge only so far; Safari and Firefox ignore it and
   draw an ordinary 12px rounded corner, which is a fine fallback. */
.detail-close {
  position: absolute; top: var(--detail-gap); right: var(--detail-gap); z-index: 2;
  width: var(--close); height: var(--close);
  border-radius: 12px; corner-shape: squircle;
  background: var(--chip); color: var(--ink-soft);
  font-size: 15px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  transition: background .2s ease, color .3s ease;
}
.detail-close:hover { background: var(--chip-hover); }
/* The same gutter on all four sides. The close button sits in the top-right
   corner, so the title can start right at the top beside it; once you
   scroll, content slides under the button. */
.detail-body {
  overflow-y: auto; flex: 1;
  padding: var(--detail-gap);
}
/* The last image's bottom margin would stack on the bottom padding (margins
   don't merge into a scrolling box's padding), doubling the gap there. */
.detail-body > :last-child { margin-bottom: 0; }
/* The title shares the top row with the close button. line-height matches
   the button's height, so the first line is centred on it, and the right
   padding stops a long title running underneath it — it wraps a gutter
   short of the button instead. */
.detail-body h2 {
  font-family: var(--font-mono); font-weight: 400; font-size: 24px;
  line-height: var(--close);
  padding-right: calc(var(--close) + var(--detail-gap));
}
.detail-role { color: var(--gray-mute); font-size: 13px; margin-bottom: 20px; }
.detail-body img,
.detail-body video { width: 100%; border-radius: 8px; background: var(--surface-sunken); margin: var(--detail-gap) 0; }
/* A video is inline by default (the reset only makes img block), which sits
   it on a text line — its margins then add to the image's next to it instead
   of merging, and leave a gap under it, so the space around a video came out
   more than twice the space between two images. */
.detail-body video { display: block; }
/* A hairline edge, so shots with a white background don't dissolve into the
   white panel. border-box (from the reset) keeps it inside the 100% width. */
.detail-body img,
.detail-body video { border: 1px solid var(--plate-edge); }
/* An image or video given width/height attributes keeps that shape before it
   loads — a grey plate holds its place, so nothing below it jumps as it
   arrives. height: auto lets it scale with the width. The logo lockup is left
   without attributes on purpose: its height comes from its own rule below,
   which this one would outrank. */
.detail-body img[width][height],
.detail-body video[width][height] { height: auto; }
.detail-body p { font-size: 14px; line-height: 1.65; color: var(--prose); margin-bottom: 14px; max-width: 62ch; }

/* Two portrait phone clips side by side — full-width each was absurdly tall
   on desktop. Row shares the vertical margin so it reads as one image. */
.detail-img-row { display: flex; gap: var(--detail-gap); margin: var(--detail-gap) 0; }
.detail-img-row .detail-img { width: auto; flex: 1 1 0; min-width: 0; margin: 0; }

/* A logo lockup is a small flat graphic — full width upscales it into a
   blurry block that's taller than the panel. So the img box still spans the
   column (for the white plate), but object-fit centres the artwork inside it
   at its own size. White in both themes: it's a brand asset drawn for white,
   and the sunken plate would tint it. Scoped to .detail-body so it outranks
   `.detail-body img` above, which otherwise wins the background. */
.detail-body .detail-img-logo {
  width: 100%; height: clamp(240px, 42vw, 360px);
  object-fit: contain;
  padding: 32px; box-sizing: border-box;
  background: #fff;
  border: 1px solid var(--plate-edge);
}

/* A single tall portrait shot — at full column width it runs taller than the
   panel. So the box still spans the column like the images around it, but
   stops at 450px tall; object-fit: contain centres the shot in it and the
   white background fills the sides, matching the shot's own white. On a
   phone the column is narrow enough that the shot fills its box before
   reaching the cap. */
.detail-body .detail-img-narrow {
  max-height: 450px;
  object-fit: contain;
  background: #fff;
}

/* A set of those narrow shots (App Store screenshots). One column on phones
   and tablets held upright; two to a row on desktop and tablets turned on
   their side, which halves how far the set runs down the page. 1025px is the
   split: the widest iPad in portrait is 1024px, the narrowest in landscape
   1080px. The grid gap matches the space the images would have between them
   stacked on their own. */
.detail-img-grid { display: grid; gap: var(--detail-gap); margin: var(--detail-gap) 0; }
.detail-img-grid .detail-img { margin: 0; }
@media (min-width: 1025px) {
  .detail-img-grid { grid-template-columns: 1fr 1fr; }
}

/* An App Store button, centred at the foot of a case study. Copied from the
   Shutterfoot site's own button — pill, Apple mark, soft amber glow — so it
   reads as the app's rather than this site's. Inter 500 where the original
   is 600: only 400 and 500 are loaded, and a missing weight gets faked by
   thickening the one below it. */
.detail-cta { display: flex; justify-content: center; margin-top: 40px; }
.store-btn {
  display: inline-flex; align-items: center; gap: 12px;
  padding: 16px 32px; border-radius: 999px;
  background: var(--store-btn); color: var(--store-btn-ink);
  font-size: 16px; font-weight: 500; line-height: 24px;
  text-decoration: none;
  box-shadow: 0 8px 32px rgba(245, 158, 11, .25);
  transition: transform .2s var(--ease-out);
}
.store-btn svg { width: 20px; height: 20px; flex: none; }
.store-btn:hover { transform: scale(1.03); }
.store-btn:active { transform: scale(.98); }
.store-btn:focus-visible { outline: 2px solid var(--accent-b); outline-offset: 3px; }

/* A full-length portrait phone recording (600×1016). The box spans the column
   like the images above it, but its height stops at --tall-cap, so on
   desktop the whole video fits in the floating panel without scrolling.
   object-fit: contain centres the video in that box, and the background
   fills the sides — #fdfdfd, not #fff, because that's the recording's own
   background, so the join is invisible. aspect-ratio reserves the space
   before the video loads, so nothing jumps. The phone breakpoint loosens
   the cap (see below). */
.detail-body .detail-video-portrait {
  --tall-cap: min(75vh, 720px);
  aspect-ratio: 600 / 1016;
  max-height: var(--tall-cap);
  object-fit: contain;
  background: #fdfdfd;
}

/* The flying flip proxy: front face is a clone of the card, back face is a
   clone of the panel. It rotates for real — above everything, so nothing
   in the page can clip it mid-flight. */
.detail-layer.flying .detail-panel { visibility: hidden; }
.flip-proxy {
  position: fixed; z-index: 60;
  perspective: 1400px;
  pointer-events: none;
}
.flip-inner {
  position: relative; width: 100%; height: 100%;
  transform-style: preserve-3d;
}
.flip-front, .flip-back {
  position: absolute; inset: 0;
  backface-visibility: hidden; -webkit-backface-visibility: hidden;
  border-radius: 12px; overflow: hidden;
  /* the faces are sealed units: nothing inside them can move anything
     outside, so the browser can skip re-checking the rest of the page on
     every frame of the resize */
  contain: layout paint;
}
/* a scroll container being resized 60 times a second re-measures itself
   every frame; the clone can't be scrolled anyway */
.flip-back .detail-body { overflow: hidden; }
/* the clone's own shadow gets clipped by overflow, so the face carries the
   card's shadow itself — otherwise the shadow pops in at the landing swap */
.flip-front {
  box-shadow: var(--surface-edge), var(--card-shadow);
}
.flip-front .card {
  width: 100%; height: 100%;
  /* the clone keeps whatever shape the real card has right now — open in
     More, image-only in Less — so the flip starts on the exact
     picture that was on screen. It just mustn't re-run the transition. */
  transition: none;
  box-shadow: none;
  cursor: default;
}
.flip-back {
  transform: rotateY(180deg);
  background: var(--surface); border-radius: 16px;
  box-shadow: var(--panel-shadow);
}
/* the cloned panel fills the back face exactly (its normal centering
   and sizing rules don't apply inside the proxy) */
.flip-back .detail-panel {
  position: absolute; inset: 0;
  width: 100%; height: 100%; margin: 0;
  border-radius: inherit; box-shadow: none;
}

/* ==========================================================================
   Toggle — 64×32 track, two stops, knob at 4 / 36
   (js/main.js holds the same two numbers in KNOB_X)
   ========================================================================== */
.toggle-bar {
  position: fixed; inset: auto 0 0 0; z-index: 20;
  display: flex; align-items: center; justify-content: center; gap: 10px;
  /* the big top padding is what gives the scrim room to fade over. The bar
     is bottom-anchored, so growing it upward doesn't move the controls —
     they stay 26px off the bottom edge either way. 90+32+26 = 148px, the
     same strip height as the header, so both edges fade over equal ground. */
  padding: 90px 10px 26px;
  animation: fade-in .6s ease .15s backwards;
  pointer-events: none;   /* only the controls take clicks, not the strip */
}
/* Same frosted scrim as .site-header, mirrored — see the note there for why
   it sits on a pseudo-element and why the blur needs the mask, not just a
   gradient. Here it's the knob and the labels that would otherwise ghost. */
.toggle-bar::before {
  /* Bleeds past the window edges for the same reason as .site-header::before
     — see the note there. Mirrored: `top` is the edge that has to stay put. */
  content: ''; position: absolute; inset: 0 -2px -2px -2px;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  -webkit-backdrop-filter: blur(14px);
          backdrop-filter: blur(14px);
  /* The stop count is doing real work — don't collapse it to a midpoint
     hint. A hint interpolates as a power curve, which has a vertical or
     near-vertical tangent at one end; that kink in the alpha ramp shows
     up as a faint line. The blur makes any kink twice as obvious, since
     the blur fades with the mask and the eye reads a sudden change in
     blur strength far more readily than a change in tint. These stops
     trace an S-curve whose slope changes gently the whole way down. */
  -webkit-mask-image: linear-gradient(to top,
    #000 0%, #000 28%,
    rgba(0,0,0,.93) 40%, rgba(0,0,0,.80) 52%, rgba(0,0,0,.60) 64%,
    rgba(0,0,0,.38) 76%, rgba(0,0,0,.18) 86%, rgba(0,0,0,.05) 94%,
    transparent 100%);
          mask-image: linear-gradient(to top,
    #000 0%, #000 28%,
    rgba(0,0,0,.93) 40%, rgba(0,0,0,.80) 52%, rgba(0,0,0,.60) 64%,
    rgba(0,0,0,.38) 76%, rgba(0,0,0,.18) 86%, rgba(0,0,0,.05) 94%,
    transparent 100%);
}
.toggle-bar > * { position: relative; z-index: 1; }   /* above the scrim */
.toggle-label {
  pointer-events: auto;
  font-size: 12px; color: var(--gray); width: 40px;
  transition: color .2s ease;
}
.toggle-label:hover { color: var(--ink); }
.toggle-label[data-goto="0"] { text-align: right; }
.toggle-label[data-goto="2"] { text-align: left; }

.toggle {
  pointer-events: auto;
  position: relative; width: 64px; height: 32px;
  border-radius: 50px; background: var(--track);
  box-shadow: var(--track-shadow);
  cursor: pointer; touch-action: none;
}
.toggle:focus-visible { outline: 2px solid var(--accent-b); outline-offset: 3px; }
.toggle .dot {
  position: absolute; top: 14px; width: 4px; height: 4px;
  border-radius: 50%; background: var(--dot);
}
.toggle .dot:nth-child(1) { left: 14px; }
.toggle .dot:nth-child(2) { left: 46px; }
/* --knob-pos is set by js/main.js: how far along the track the knob sits.
   It's a variable rather than `left` so the desktop rail below can measure
   it down from the top instead, without the script caring which way the
   track is standing. */
.knob {
  position: absolute; top: 4px; left: var(--knob-pos, 4px);
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--knob);
  box-shadow: var(--knob-shadow), inset 0 0 0 .5px var(--knob-ring);
  transition: left .45s var(--ease-spring),   /* the bounce */
              top .45s var(--ease-spring),    /* same bounce, on the rail */
              background-color .3s ease, box-shadow .3s ease;
  cursor: grab;
}
.toggle.dragging .knob { transition: none; cursor: grabbing; }

/* ---------- Desktop: the toggle stands up on the right edge ----------
   Wide screen AND a mouse or trackpad. Width alone isn't enough: an iPad
   turned on its side is as wide as a laptop, and tablets keep the bar at
   the bottom. A touchscreen reports that it can't hover, which is the tell.

   The track stands on end with Less at the top and More at the bottom —
   the same direction the page opens in as you add detail. The DOM already
   reads Less, track, More, so it's a plain column.

   No scrim here: the column is centred, so nothing ever scrolls under the
   right edge at these widths. */
@media (min-width: 1025px) and (hover: hover) and (pointer: fine) {
  .toggle-bar {
    /* a full-height strip, so flex can centre the control in the window.
       22px + the 4px the labels overhang the track = the track sits 26px
       off the edge, the same as it sat off the bottom. */
    inset: 0 22px 0 auto;
    flex-direction: column;
    padding: 0;
  }
  .toggle-bar::before { display: none; }
  .toggle-label[data-goto] { text-align: center; }

  .toggle { width: 32px; height: 64px; }
  .toggle .dot:nth-child(1) { left: 14px; top: 14px; }
  .toggle .dot:nth-child(2) { left: 14px; top: 46px; }
  .knob { left: 4px; top: var(--knob-pos, 4px); }
}

/* ==========================================================================
   Narrow screens — same column, tighter margins
   ========================================================================== */
@media (max-width: 700px) {
  :root { --card-hero: 225px; }

  .site-header { height: 112px; padding-top: 32px; gap: 8px; }
  .toggle-bar { padding-top: 54px; }   /* 54+32+26 = 112px, matches the header */
  .header-name, .header-tagline { font-size: 12px; }
  .column { padding-top: 96px; padding-bottom: 116px; }

  .hero h1 { font-size: 22px; }
  .lede, .cv-role, .hero-links { font-size: 17px; }
  .cv-company, .cv-year, .cv-detail { font-size: 15px; }
  .cv-detail, .cv-head { line-height: 26px; }
  /* set the variable, not gap itself — hidden cards cancel the gap with a
     negative margin read from it, so the two have to stay in step */
  .work { --work-gap: 24px; }

  /* A case study goes edge to edge on a phone — a floating panel with a
     sliver of page around it wastes the little width there is. The flip's
     back face loses its corners too, so the card lands as a square screen
     rather than a rounded one that snaps flat. */
  .detail-panel { inset: 0; width: 100%; border-radius: 0; }
  .flip-back { border-radius: 0; }
  .detail-img-row { flex-direction: column; }
  /* …except a trio of small matching tiles, which is a side-by-side
     comparison: stacked, each one would fill the screen. So it stays one
     row, with a tighter gap to leave the tiles more of the narrow width. */
  .detail-img-row.detail-img-trio { flex-direction: row; gap: 10px; }
  /* The panel is the whole screen here, so the portrait video can run
     taller — letting the column width win so the video fills its box
     instead of sitting small in the middle of it. */
  .detail-body .detail-video-portrait { --tall-cap: 85vh; }
}

/* Phones: a job title, its company and its year won't sit on one line, and
   letting them wrap freely strands the year by itself. So the title takes
   the first line and the company + year share the second. */
@media (max-width: 460px) {
  .cv-role { flex: 1 0 100%; }
}

/* While the page boots, jump straight to the state in the URL — no transitions */
body.booting *, body.booting *::before, body.booting *::after {
  transition: none !important;
}

/* ---------- Accessibility: no motion if the OS asks for none ---------- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: .01ms !important;
    transition-delay: 0s !important;
    animation-duration: .01ms !important;
    animation-delay: 0s !important;
  }
}
