/* ==========================================================================
   Olivia Sherwood — Product Designer portfolio (homepage, fresh build)

   A clean, fully monochrome recreation of her original Home.pdf layout:
   header, intro block, single-column project list with auto-advancing image
   carousels, footer. No colour in the site's own chrome — the only colour on
   the page comes from the real app screenshots inside the carousels.

   Plain CSS + a small vanilla-JS carousel. No framework, no build step.
   Organised: tokens -> reset -> a11y -> type -> layout -> header -> intro
   -> project list -> carousel -> footer.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Design tokens — black / white / grey only
   -------------------------------------------------------------------------- */
:root {
  /* Tells the browser which canvas to paint before our CSS applies. Without it
     the gap between documents flashes white, which no page transition can hide
     because it happens outside the page. Also themes scrollbars and form UI. */
  color-scheme: light;

  --bg: #FAFAFA;          /* page background, warm-neutral off-white */
  --ink: #111111;         /* headlines + body text */
  --muted: #6B6B6B;       /* secondary text */
  --line: #E5E5E5;        /* dividers / borders */
  --panel: #F0F0F0;       /* carousel frame backdrop, subtle fills */
  --white: #FFFFFF;
  /* Translucent header fill, used only where backdrop-blur is supported;
     elsewhere the header falls back to solid --bg. Same colour as --bg. */
  --header-bg: rgba(250, 250, 250, 0.82);

  /* The toggle (31px) is the tallest thing in the bar, so header height is its
     two paddings plus that. Drives the sticky nav's anchor offset. */
  --header-height: calc(2 * var(--space-md) + 31px);

  --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  /* Fluid type — scales with viewport rather than jumping at breakpoints. */
  --size-hero: clamp(2.25rem, 4.5vw + 1rem, 4.75rem);
  --size-h2: clamp(1.5rem, 2vw + 1rem, 2.25rem);
  --size-h3: clamp(1.5rem, 1.2vw + 1rem, 2rem);
  --size-lede: clamp(1.125rem, 0.6vw + 1rem, 1.4rem);
  --size-body: 1rem;
  --size-label: 0.8125rem;

  /* Fluid space — scales with the viewport, exactly as the type scale does.
     The type was already fluid while these were fixed, so on a phone the gaps
     stayed at desktop size while the text shrank, and everything drifted apart.

     Each clamp interpolates between a 375px-wide phone and a 1280px desktop:
       token    375px -> 1280px
       sm         12  ->  16
       md         16  ->  24
       lg         24  ->  40
       xl         32  ->  64      (tight sections)
       2xl        40  -> 104      (major sections)
     xs stays fixed — 8px is already the smallest useful step. */
  --space-xs: 0.5rem;
  --space-sm: clamp(0.75rem, 0.44vw + 0.65rem, 1rem);
  --space-md: clamp(1rem,    0.88vw + 0.79rem, 1.5rem);
  --space-lg: clamp(1.5rem,  1.77vw + 1.09rem, 2.5rem);
  --space-xl: clamp(2rem,    3.54vw + 1.17rem, 4rem);
  --space-2xl: clamp(2.5rem, 7.07vw + 0.84rem, 6.5rem);

  /* Comfortable reading width for running text, even when the surrounding
     layout stretches to full width on large screens. */
  /* Drives both the duration and the visibility delay of the mobile nav, so
     reduced-motion can zero the delay too — transition-duration: 0 alone would
     still leave the panel visible for the length of the delay. */
  --nav-anim: 0.3s;

  --measure: 38rem;

  /* Full-width layout: effectively edge-to-edge on normal monitors, with a
     high cap so it doesn't get absurd on ultra-wide displays. Fluid gutter. */
  --max-width: 120rem;
  --gutter: clamp(1.25rem, 4vw, 4rem);
}

/* --------------------------------------------------------------------------
   1b. Dark theme tokens

   Inverted monochrome, tuned rather than flipped 1:1 so secondary text and
   dividers keep enough contrast on a dark ground:
     bg #151412 (warm near-black) + ink #F1EEE8  -> ~16:1  (body text)
     muted #A29E96 on bg                          -> ~7:1   (passes AA text)
     line #454239 on bg                           -> ~2:1   (visible divider)
     panel #201E1A                                -> a hair lighter than bg,
                                                     so framed tiles read as surfaces

   Applied when the OS prefers dark AND the user hasn't chosen light, or when
   the user has explicitly toggled dark. Only the colour tokens change — every
   component reads from them, so nothing else needs per-theme rules.
   -------------------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #151412;
    --ink: #F1EEE8;
    --muted: #A29E96;
    --line: #454239;
    --panel: #201E1A;
    --header-bg: rgba(21, 20, 18, 0.82);
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #151412;
  --ink: #F1EEE8;
  --muted: #A29E96;
  --line: #454239;
  --panel: #201E1A;
  --header-bg: rgba(21, 20, 18, 0.82);
}

/* Smooth, subtle cross-fade while switching theme. Applied via a class that
   JS adds for the duration of the switch only, so it never interferes with
   other interactions. The reduced-motion block further below forces
   transition-duration to ~0 (with !important), so those users still get the
   mode change — just instantly, with no animation. */
.theme-transition,
.theme-transition *,
.theme-transition *::before,
.theme-transition *::after {
  transition: background-color 0.3s ease, color 0.3s ease,
              border-color 0.3s ease, fill 0.3s ease;
}
/* keep the toggle knob's springy slide during the switch (the universal
   .theme-transition rule would otherwise drop transform/width from it) */
.theme-transition .theme-toggle__knob {
  transition:
    transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
    width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* --------------------------------------------------------------------------
   1c. Cross-document page transitions

   This is the multi-page View Transitions API, not document.startViewTransition().
   That function is the *same-document* API: it snapshots, runs a callback that
   mutates the DOM, then animates. A real navigation destroys the old document,
   so wrapping location.href in it does nothing. For an MPA the opt-in is this
   at-rule, declared on both the outgoing and incoming page (both load this
   stylesheet, so that's satisfied). Same-origin only, per spec — external links
   never transition.

   No JS. Browsers without support ignore the at-rule and navigate normally.

   Scroll position needs no JS either: a fresh navigation already lands at the
   top, and history.scrollRestoration stays "auto" so the back button restores
   where you were. Forcing it to "manual" would break that.
   -------------------------------------------------------------------------- */
@view-transition { navigation: auto; }

/* Pull the header out of the root snapshot and give it its own. Without this
   the header is part of `root`, so it dissolves into a pixel-identical copy of
   itself — which is what makes a whole-page crossfade read as flat. Named, it
   holds still while the content moves beneath it: the header is furniture,
   not content.

   Only the header. Two deliberate omissions:

   `main` stays inside `root`. A named group tweens between its old and new
   geometry, and main's height differs enormously between the homepage and the
   visuals mosaic, so a named main would visibly squash. `root` is always sized
   to the viewport, so it can slide without distorting.

   The footer stays inside `root` too, for the same reason in reverse: it sits
   at a different y on every page, so naming it would make the group slide the
   footer across the viewport whenever you navigate from a scrolled-down
   position. The header is at y=0 everywhere, so its group tween is a no-op. */
.site-header { view-transition-name: site-header; }

/* Header: furniture — hold it perfectly still across a navigation.

   Naming it already lifts it out of the root crossfade. But two things still
   made the bar jitter when tapping between pages:
     1) the GROUP would tween any sub-pixel geometry delta between the two
        pages' headers, and
     2) cross-fading the old and new header snapshots double-exposed the parts
        that legitimately differ per page — the active-link underline and the
        live clock (which JS relocates into the header) — ghosting as a shimmer.
   Pin the group (no geometry tween) and swap the snapshots instantly rather than
   cross-fading, so the header just shows the new page's state with no motion. */
::view-transition-group(site-header) { animation-duration: 0s; }
::view-transition-old(site-header) { animation: none; opacity: 0; }
::view-transition-new(site-header) { animation: none; opacity: 1; }

/* Everything left in `root` — the page background and the footer — simply
   crossfades. No movement, so the footer can never slide across the viewport
   from a scrolled-down position. The content blocks below ride on top of this
   with the real motion. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

/* Staggered content entrance — the chrome://whats-new quality.

   Each top-level block is its own snapshot (named further down), so the
   incoming page doesn't land as one rigid sheet: the hero settles first, then
   the block beneath it a beat later, each fading up on a confident ease-out —
   the same decelerating curve family Chrome uses in its own UI. Outgoing blocks
   leave together and quickly; only the arrival is staged, which is exactly what
   separates a polished load-in from an abrupt one.

   transform + opacity only, so every frame is compositor-only — no layout, no
   paint — smooth even on the visuals page with its wall of images. The single
   shared curve means nothing fights, and the short durations keep it snappy. */
::view-transition-old(vt-home-1),
::view-transition-old(vt-home-2),
::view-transition-old(vt-home-3),
::view-transition-old(vt-visuals-1),
::view-transition-old(vt-case-1),
::view-transition-old(vt-case-2),
::view-transition-old(vt-case-3),
::view-transition-old(vt-case-4),
::view-transition-old(vt-case-5),
::view-transition-old(vt-case-6) {
  animation: vt-content-out 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
::view-transition-new(vt-home-1),
::view-transition-new(vt-home-2),
::view-transition-new(vt-home-3),
::view-transition-new(vt-visuals-1),
::view-transition-new(vt-case-1),
::view-transition-new(vt-case-2),
::view-transition-new(vt-case-3),
::view-transition-new(vt-case-4),
::view-transition-new(vt-case-5),
::view-transition-new(vt-case-6) {
  animation: vt-content-in 360ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

/* The stagger itself: each block starts a beat after the one above it. Delays
   are capped so blocks far down the page (mostly off-screen anyway) have
   entered by the time you scroll to them, rather than sitting there waiting. */
::view-transition-new(vt-home-2),
::view-transition-new(vt-case-2) { animation-delay: 70ms; }
::view-transition-new(vt-home-3),
::view-transition-new(vt-case-3) { animation-delay: 140ms; }
::view-transition-new(vt-case-4),
::view-transition-new(vt-case-5),
::view-transition-new(vt-case-6) { animation-delay: 190ms; }

/* Name the blocks. Page-scoped (page-home / page-visuals / page-case on <body>)
   so a block on one page never shares a name with a block on another — a shared
   name would make the browser tween the two blocks' differing geometry (the
   squash/slide the header comment above avoids) instead of a clean enter. Being
   page-unique, every block is only ever enter-only or exit-only. */
.page-home    main > section:nth-of-type(1) { view-transition-name: vt-home-1; }
.page-home    main > section:nth-of-type(2) { view-transition-name: vt-home-2; }
.page-home    main > section:nth-of-type(3) { view-transition-name: vt-home-3; }
.page-visuals main > section:nth-of-type(1) { view-transition-name: vt-visuals-1; }
.page-case    main > section:nth-of-type(1) { view-transition-name: vt-case-1; }
.page-case    main > section:nth-of-type(2) { view-transition-name: vt-case-2; }
.page-case    main > section:nth-of-type(3) { view-transition-name: vt-case-3; }
.page-case    main > section:nth-of-type(4) { view-transition-name: vt-case-4; }
.page-case    main > section:nth-of-type(5) { view-transition-name: vt-case-5; }
.page-case    main > section:nth-of-type(6) { view-transition-name: vt-case-6; }

@keyframes vt-content-out {
  to { opacity: 0; transform: translateY(-8px); }
}
@keyframes vt-content-in {
  from { opacity: 0; transform: translateY(16px); }
}

/* The global reduced-motion rule below uses `*`, which cannot match the
   view-transition pseudo-element tree — it lives outside the document. So the
   animations have to be killed explicitly. Navigation still happens, instantly. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* --------------------------------------------------------------------------
   2. Reset
   -------------------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Every in-page anchor lands below the sticky header instead of behind it.
     One rule covers #about-me, #work, the skip link — every target — so no
     per-section scroll-margin is needed. The extra --space-sm is breathing room. */
  scroll-padding-top: calc(var(--header-height) + var(--space-sm));
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--size-body);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

img { max-width: 100%; display: block; }
h1, h2, h3, p, figure, ul { margin: 0; }
ul { padding: 0; list-style: none; }
a { color: inherit; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  :root { --nav-anim: 0s; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* --------------------------------------------------------------------------
   3. Accessibility helpers
   -------------------------------------------------------------------------- */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  top: -100%;
  left: var(--space-sm);
  background: var(--ink);
  color: var(--bg);
  padding: var(--space-xs) var(--space-sm);
  border-radius: 6px;
  z-index: 200;              /* above the sticky header (100), or it'd hide behind it when focused */
  text-decoration: none;
  transition: top 0.15s ease;
}
.skip-link:focus { top: var(--space-sm); }

/* One clear focus treatment everywhere. */
:focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 3px;
}

/* --------------------------------------------------------------------------
   4. Typography
   -------------------------------------------------------------------------- */
h1, h2, h3 {
  font-weight: 900;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

h1 { font-size: var(--size-hero); }
h2 { font-size: var(--size-h2); }
h3 { font-size: var(--size-h3); }

.eyebrow {
  font-size: var(--size-label);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--muted);
}

/* Running text keeps a readable measure regardless of layout width. */
.lede { font-size: var(--size-lede); max-width: var(--measure); }
.prose { max-width: var(--measure); color: var(--ink); }

/* --------------------------------------------------------------------------
   5. Layout
   -------------------------------------------------------------------------- */
.container {
  width: 100%;
  max-width: var(--max-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.section { padding-block: var(--space-2xl); }
.section--tight { padding-block: var(--space-xl); }

.divider { border: 0; border-top: 1px solid var(--line); margin: 0; }

/* --------------------------------------------------------------------------
   6. Header / nav
   -------------------------------------------------------------------------- */
.site-header {
  padding-block: var(--space-md);
  position: sticky;
  top: 0;
  z-index: 100;              /* above hero images, the mosaic grid, everything */
  background: var(--bg);     /* solid fallback: opaque so content can't show through */
}

/* Only where backdrop-blur actually works do we go translucent — otherwise the
   solid --bg above stands, rather than a see-through header with no blur. */
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .site-header {
    background: var(--header-bg);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
  }
}

.site-header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
}

.site-logo {
  font-weight: 900;
  font-size: 1.125rem;
  letter-spacing: -0.02em;
  text-decoration: none;
  white-space: nowrap; /* never let the name break across two lines */
}

/* Header clock slot (JS moves the single .footer__clock here on wide screens).
   margin-right: auto keeps it and the logo grouped on the left while the nav
   actions stay pushed to the right. Matches the nav links' font family, size
   and weight, but keeps a muted colour so it reads as ambient detail. */
.site-header__clock {
  display: flex;
  align-items: center;
  margin-inline: var(--space-md) auto;
}
.site-header__clock .footer__clock {
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--muted);
}

/* Links, theme toggle and hamburger sit in one row on the right. */
.site-header__actions { display: flex; align-items: center; gap: var(--space-md); }

.site-nav ul { display: flex; gap: var(--space-md); align-items: center; }

.site-nav a {
  text-decoration: none;
  font-weight: 600;
  font-size: 0.95rem;
  padding-block: 0.2rem;
  border-bottom: 2px solid transparent;
  transition: border-color 0.15s ease;
}
.site-nav a:hover,
.site-nav a[aria-current="page"] { border-color: var(--ink); }

/* --- Hamburger -------------------------------------------------------------
   Hidden on desktop. A single bar element draws the middle line; its ::before
   and ::after draw the outer two. Open state rotates the outer bars into an X
   and hides the middle one. */
.nav-toggle {
  display: none;
  position: relative;
  width: 44px;               /* full 44px tap target, no ::before needed */
  height: 44px;
  margin-right: -10px;       /* optically align the bars with the gutter */
  padding: 0;
  border: none;
  background: none;
  color: var(--ink);
  cursor: pointer;
}

.nav-toggle__bar,
.nav-toggle__bar::before,
.nav-toggle__bar::after {
  position: absolute;
  left: 50%;
  width: 22px;
  height: 2px;
  margin-left: -11px;
  background: currentColor;
  transition: transform var(--nav-anim) ease, top var(--nav-anim) ease,
              background-color var(--nav-anim) ease;
}
.nav-toggle__bar { top: 50%; margin-top: -1px; }
.nav-toggle__bar::before,
.nav-toggle__bar::after { content: ""; left: 0; margin-left: 0; }
.nav-toggle__bar::before { top: -7px; }
.nav-toggle__bar::after  { top: 7px; }

/* Open: middle bar goes transparent (kept in flow so the outer two can pivot
   around its centre), outer bars slide in and cross. */
[data-nav-open="true"] .nav-toggle__bar { background: transparent; }
[data-nav-open="true"] .nav-toggle__bar::before { top: 0; transform: rotate(45deg); }
[data-nav-open="true"] .nav-toggle__bar::after  { top: 0; transform: rotate(-45deg); }

/* --- Mobile nav panel ------------------------------------------------------
   Below 48rem the links drop out of the header row into a panel underneath it.
   The open/close animation uses grid-template-rows 0fr→1fr so it works from
   the content's real height without hardcoding one. `visibility` keeps the
   links out of the tab order while closed, and its delay is driven by
   --nav-anim so reduced-motion users get an instant, flash-free toggle. */
@media (max-width: 47.99rem) {
  .nav-toggle { display: block; }

  /* The hamburger's 44px tap target is taller than the 31px toggle here, so it,
     not the toggle, sets the header height. Keep the anchor offset in step. */
  :root { --header-height: calc(2 * var(--space-md) + 44px); }

  .site-nav {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    z-index: 50;
    display: grid;
    grid-template-rows: 0fr;
    background: var(--bg);
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
    opacity: 0;
    visibility: hidden;
    transition: grid-template-rows var(--nav-anim) ease,
                opacity var(--nav-anim) ease,
                visibility 0s var(--nav-anim);
  }

  .site-nav > ul {
    display: block;
    min-height: 0;   /* required for the 0fr row to actually collapse */
    overflow: hidden;
  }

  .site-nav li { padding-inline: var(--gutter); }
  .site-nav li:first-child { padding-top: var(--space-md); }
  .site-nav li:last-child  { padding-bottom: var(--space-md); }

  .site-nav a {
    display: block;
    padding-block: var(--space-sm);
    font-size: 1.0625rem;
    border-bottom: none;
  }
  /* underline reads as an odd half-width rule on a stacked list */
  .site-nav a:hover,
  .site-nav a[aria-current="page"] { text-decoration: underline; text-underline-offset: 4px; }

  [data-nav-open="true"] .site-nav {
    grid-template-rows: 1fr;
    opacity: 1;
    visibility: visible;
    transition: grid-template-rows var(--nav-anim) ease,
                opacity var(--nav-anim) ease,
                visibility 0s 0s;
  }
}

/* --- Theme toggle switch ---------------------------------------------------
   A 1:1 rebuild of the iOS native switch, in monochrome instead of green.
   Geometry is in px, not rem, because these are exact device-pixel specs and
   must not rescale with root font-size.

     track 51 x 31, radius 15.5   knob 27, inset 2px all round
     travel = 51 - 27 - 2 - 2 = 20px

   It's a real <button role="switch">, so space/enter and focus come for free;
   the global :focus-visible ring covers the focus state. */
.theme-toggle {
  position: relative;
  width: 51px;
  height: 31px;
  flex-shrink: 0;
  padding: 0;
  border: none;
  border-radius: 15.5px;
  /* iOS's switch is #E9E9EA, but it sits on white cards inside grey grouped
     sections. On this page's #FAFAFA that's a 1.16:1 contrast — a near-invisible
     ghost of a track. systemGray4 gets it to ~1.45:1 so it reads as a real pill. */
  background: #D1D1D6;          /* off */
  cursor: pointer;
  transition: background-color 0.3s ease;
}
.theme-toggle:focus-visible { outline-offset: 3px; }

/* Comfortable ~47px touch target on mobile without enlarging the visual. */
.theme-toggle::before {
  content: "";
  position: absolute;
  inset: -8px;
}

.theme-toggle__knob {
  position: absolute;
  top: 2px;
  left: 2px;
  width: 27px;
  height: 27px;
  border-radius: 50%;
  background: #FFFFFF;
  /* A single soft drop shadow, as iOS uses. Nothing with spread: a spread ring
     paints outside the 27px box and makes the knob read ~28px against a 31px
     track, which is what reads as "chunky" even when the geometry is exact. */
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
  /* springy slide with a touch of overshoot, like the native control.
     transform (not `left`) so it's composited on the GPU and can't reflow. */
  transition:
    transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
    width 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* On (= dark mode active): track fills near-black, knob slides its 20px. */
.theme-toggle[aria-checked="true"] { background: #1A1A1A; }
.theme-toggle[aria-checked="true"] .theme-toggle__knob { transform: translateX(20px); }

/* First paint only: JS sets the switch's real state a beat after load, so in
   dark mode the knob would slide and the track fade in from "off" on every
   navigation. While booting, kill those transitions so the switch just appears
   correct; theme.js drops this class a frame later and clicks animate again. */
.theme-booting .theme-toggle,
.theme-booting .theme-toggle__knob { transition: none; }

/* The on state only ever coincides with dark mode, so a #1A1A1A track would
   otherwise sit near-invisibly on the #151412 page. A hairline inset ring
   restores the track's silhouette without changing its size or fill. */
:root[data-theme="dark"] .theme-toggle[aria-checked="true"] {
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle[aria-checked="true"] {
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
  }
}

/* iOS polish: the knob elongates a few px in its direction of travel while
   pressed, then settles. Off, it grows rightwards from its fixed left edge.
   On, we hold the *right* edge at 49px and grow leftwards: 49 - 31 = 18px
   left edge, i.e. 16px of translate from the 2px origin. */
.theme-toggle:active .theme-toggle__knob { width: 31px; }
.theme-toggle[aria-checked="true"]:active .theme-toggle__knob {
  transform: translateX(16px);
}

/* --------------------------------------------------------------------------
   7. Intro block — the name and tagline, full width
   -------------------------------------------------------------------------- */

/* Breathing room between the eyebrow and the display heading, and a cap so the
   headline doesn't run edge-to-edge on wide screens. It stays left-aligned in
   the normal container padding. Scoped to the intro so the case study <h1>
   keeps its own spacing.

   Fluid size via clamp: smaller footprint than before (54px cap, was 68px) so
   the first project peeks above the fold, but still 900-weight with the tight
   -0.02em tracking inherited from the base h1 rule, so it reads as a display
   headline, not shrunk body text. The 48rem measure lets the desktop size
   break to two lines instead of three, cutting the hero's height further. */
#intro-heading {
  margin-top: var(--space-sm);
  max-width: 48rem;
  font-size: clamp(1.75rem, 4.2vw, 3.375rem);
}

/* The role sits on its own line under the greeting, with a small break that
   scales with the (fluid) heading size. */
#intro-heading .intro__role { display: block; margin-top: 0.5em; }

/* Sub-copy under the headline: lighter and smaller, capped at a readable
   measure while the layout around it stays full-width. */
.intro__sub {
  font-size: var(--size-lede);
  font-weight: 400;
  line-height: 1.4;
  color: var(--muted);
  max-width: var(--measure);
}

/* Trim the hero's vertical footprint (its own padding is lighter than the
   default section--tight) so the top of the first project clears the fold on
   short laptops. The sticky header already separates it from the top, so it
   needs little padding above. */
.section--tight[aria-labelledby="intro-heading"] {
  padding-block: var(--space-md) var(--space-md);
}

/* Visible label for the project list. Styled as an eyebrow (it reuses
   .eyebrow) but marked up as the section's real <h2>, so the heading order
   runs h1 → h2 "Selected work" → h3 project titles. */
.work__heading { margin-bottom: var(--space-md); }

/* --------------------------------------------------------------------------
   7b. About me

   Same rhythm as the project rows: heading in the left column, running copy in
   the right, capped at the reading measure. No border, fill, or different type
   — it should read as a continuation of the page, not a distinct block.
   -------------------------------------------------------------------------- */
/* No per-section scroll-margin: html{scroll-padding-top} now offsets every
   anchor for the sticky header in one place. */
.about__grid { display: grid; grid-template-columns: 1fr; gap: var(--space-md); }

@media (min-width: 60rem) {
  .about__grid {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-2xl);
    align-items: start;
  }
}

.about__copy { max-width: var(--measure); }
.about__copy p + p { margin-top: var(--space-md); }

/* --------------------------------------------------------------------------
   8. Project list — single-column stacked entries
   -------------------------------------------------------------------------- */
.project-list { display: flex; flex-direction: column; }

.project {
  padding-block: var(--space-2xl);
  border-top: 1px solid var(--line);
}
.project:first-child { border-top: 0; padding-top: var(--space-lg); }

.project__body { display: grid; grid-template-columns: 1fr; gap: var(--space-md); margin-top: var(--space-md); /* 24px gap below the carousel */ }

@media (min-width: 60rem) {
  .project__body {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-2xl);
    align-items: start;
  }
}

/* Project titles are h2 semantically (top-level blocks under the page h1),
   but keep this display size regardless of the base h2 scale. */
.project__title { margin-top: 0.25rem; font-size: var(--size-h3); }

/* Only projects with a case study page get linked; the rest stay plain text. */
.project__link {
  text-decoration: none;
  border-bottom: 3px solid transparent;
  transition: border-color 0.15s ease;
}
.project__link:hover { border-color: var(--ink); }

.see-project {
  display: inline-block;
  margin-top: var(--space-md);
  font-weight: 600;
  font-size: var(--size-label);
  text-decoration: none;
  border-bottom: 2px solid var(--ink);
  padding-bottom: 0.1rem;
}
/* The margin only separates the link from a role line above it. With no role
   line, the link is first in its column and must sit flush with the eyebrow
   opposite, not 24px below it. */
.see-project:first-child { margin-top: 0; }

.see-project:hover { color: var(--muted); border-color: var(--muted); }
.project__desc { font-size: var(--size-lede); margin-top: var(--space-sm); }

/* --------------------------------------------------------------------------
   9. Carousel — ambient, auto-advancing, images shown uncropped
      Horizontally scroll-snapping row: ~1 image on mobile, up to 3 on wide
      screens. JS advances it slowly; CSS keeps it usable without JS too.
   -------------------------------------------------------------------------- */
.carousel {
  display: flex;
  gap: var(--space-sm);
  overflow-x: auto;                      /* reduced-motion / no-JS fallback: scrollable */
  scrollbar-width: none;                 /* Firefox: hide scrollbar */
  -ms-overflow-style: none;
  touch-action: pan-y;                   /* let vertical page scroll pass through */
}
.carousel::-webkit-scrollbar { display: none; }  /* WebKit: hide scrollbar */

/* Don't ghost-drag or select the images when pressing/holding to pause. */
.carousel img { -webkit-user-drag: none; user-select: none; }

/* Fixed 440 x 440 square image tiles. On viewports too narrow for 440px
   they scale down to fit (staying square). carousel.js scrolls the row
   continuously and clones each project's images so the loop is seamless. */
.carousel__slide {
  flex: 0 0 617.5px;
  max-width: 617.5px;
}

/* Below ~700px a 617.5px tile won't fit — scale it down proportionally so it
   keeps the same 617.5 x 590 shape. */
@media (max-width: 44rem) {
  .carousel__slide { flex-basis: 84vw; }
}

/* 617.5 x 590 tile. The image fills it completely (object-fit: cover), so it
   crops rather than letterboxes. The frame is an <a> for projects that have a
   case study page, and a plain <div> for those that don't. */
.carousel__frame {
  display: block;
  width: 100%;
  aspect-ratio: 617.5 / 590;
  background: var(--panel);
  overflow: hidden;
}

a.carousel__frame { text-decoration: none; }

/* Subtle zoom-in on hover/focus. The frame clips the overflow, so the image
   scales inside a fixed box rather than nudging the layout. */
.carousel__frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.25s ease;
}
.carousel__frame:hover img,
.carousel__frame:focus-visible img { transform: scale(1.04); }

/* Reduced motion: drop the zoom entirely rather than snapping to it instantly
   (the global reduced-motion rule zeroes the transition duration). Links still
   work exactly as before. */
@media (prefers-reduced-motion: reduce) {
  .carousel__frame:hover img,
  .carousel__frame:focus-visible img { transform: none; }
}

/* Under reduced-motion the JS stops auto-advancing; the row stays a normal
   horizontal scroll area the user controls. */

/* --------------------------------------------------------------------------
   9b. Case study template

   Reusable across case study pages: a title block, prose sections capped at a
   readable measure, uncropped figures (full-width or a two-up grid), and an
   impact block that gives the numbers real weight. Everything reads from the
   same monochrome tokens as the homepage, so it themes automatically.
   -------------------------------------------------------------------------- */
/* One comfortable reading measure for every text block on a case study page.
   Left-aligned inside the page's full-width padding, so copy reads as an
   intentional column instead of sprawling across a wide screen — while the hero
   and full-bleed figures still run edge to edge. Fluid rather than a fixed cap:
   ~800-900px on typical large desktops, scaling down to a 600px floor on
   smaller screens instead of hard-capping identically everywhere. */
.page-case { --cs-measure: clamp(600px, 55vw, 900px); }

.cs-back {
  display: inline-block;
  margin-bottom: var(--space-lg);
  font-weight: 600;
  font-size: var(--size-label);
  text-decoration: none;
  border-bottom: 2px solid transparent;
  padding-bottom: 0.1rem;
}
.cs-back:hover { border-color: var(--ink); }

.cs-title { margin-top: var(--space-md); }

/* Everything in the reading column shares one measure (see --cs-measure). The
   hero, full-bleed figures and two-up image grids are deliberately excluded so
   they can still use the full page width. */
.page-case .eyebrow,
.cs-title,
.cs-section h2 { max-width: var(--cs-measure); }

/* Title/intro two-column split — the case-study TITLE BLOCK ONLY. Title on the
   left, intro + meta on the right; tops aligned. Stacks to one column on tablet
   and below. Deliberately its own class (not shared with the sections) and with
   no shared-selector rules, so it can't affect the section layout, the homepage,
   or the nav. */
.cs-titlesplit {
  margin-top: var(--space-md);          /* space below the eyebrow */
  max-width: 78rem;
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md);
}
.cs-titlesplit > * { min-width: 0; }    /* let the copy wrap instead of overflowing */
@media (min-width: 64rem) {
  .cs-titlesplit {
    grid-template-columns: 2fr 3fr;
    column-gap: var(--space-2xl);
    align-items: start;
  }
}
/* Inside the split the columns set the width and the tops align, so drop the
   single-column title margin and intro measure/offset. */
.cs-titlesplit .cs-title { margin-top: 0; max-width: none; }
.cs-titlesplit .cs-lede { margin-top: 0; max-width: none; }
.cs-titlesplit .cs-meta { max-width: none; }

/* Full-bleed hero: edge-to-edge, cropped to a wide cinematic band. The one
   deliberate crop on the page — heroes are speced as cover, everything else
   stays uncropped. Placed as a direct child of <main> (outside .container), so
   it already spans the viewport with no breakout hack. */
.cs-hero {
  margin: 0;
  height: clamp(14rem, 44vh, 32rem);
}
.cs-hero img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Some heroes are a full composition (e.g. a product shot that carries its own
   margin) rather than a croppable lifestyle band. Show these uncropped at their
   natural ratio — full width, height from the image — so nothing important is
   sliced off, and let srcset serve the right file size per screen. */
.cs-hero--natural { height: auto; }
.cs-hero--natural img { height: auto; background: var(--panel); }

/* Full-width supporting image: also edge-to-edge, but never cropped — it keeps
   its natural ratio and the band sizes to the image. Used for the second image
   before the content, and any other shot that earns the full width. */
.cs-fullbleed { margin: 0; }
.cs-fullbleed img {
  width: 100%;
  height: auto;
  display: block;
  background: var(--panel);
}

/* Short intro directly under the title — one line, lighter than the H1. */
.cs-lede {
  margin-top: var(--space-md);
  max-width: var(--cs-measure);
  font-size: var(--size-lede);
  color: var(--muted);
}

/* Meta block — Role / Skills and friends. Quiet reference material framing the
   opening: small uppercase labels over plain values, bordered top and bottom
   like the impact block. Stacks on mobile, sits in a row on wider screens. */
.cs-meta {
  margin: var(--space-lg) 0 0;
  max-width: var(--cs-measure);
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-lg);
  border-block: 1px solid var(--line);
  padding-block: var(--space-lg);
}
@media (min-width: 48rem) {
  .cs-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-xl); }
}
.cs-meta__label {
  display: block;
  margin-bottom: var(--space-xs);
  font-size: var(--size-label);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}
.cs-meta__value { margin: 0; font-size: var(--size-body); }

/* Skills as a plain monochrome tag list — an outlined pill each, no fill. */
.cs-meta__tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
}
.cs-meta__tags li {
  font-size: 0.9375rem;             /* was --size-label (13px) — a touch larger to read easily */
  padding: 0.5em 1.1em;             /* more room, both axes */
  color: var(--ink);                /* full-strength text, not inherited muted */
  /* --line is too faint against the page in both themes; --muted gives the
     outline real contrast on the off-white and on the near-black. */
  border: 1px solid var(--muted);
  border-radius: 999px;
}

.cs-section { padding-block: var(--space-xl); }

/* Running copy stays at a comfortable measure even though figures below can
   stretch the full width of the page. */
.cs-prose { max-width: var(--cs-measure); }
.cs-prose p { font-size: var(--size-lede); }
.cs-prose p + p { margin-top: var(--space-md); }
.cs-section h2 + .cs-prose { margin-top: var(--space-md); }

/* Figures. Images are never cropped: they keep their natural aspect ratio and
   the container sizes around them (height: auto). */
.cs-figure { margin-top: var(--space-xl); }
.cs-figure img {
  width: 100%;
  height: auto;
  background: var(--panel);
}
.cs-figure figcaption {
  margin-top: var(--space-sm);
  font-size: var(--size-label);
  color: var(--muted);
  max-width: var(--measure);
}

/* Full-bleed figure. These live OUTSIDE .container in the markup (as direct
   children of the section), so the image is simply width:100% of the full-width
   section — flush to both viewport edges, exactly like the hero. No 100vw / no
   negative-margin breakout, so nothing depends on scrollbar width (100vw counts
   the scrollbar; the content area doesn't — that mismatch is the old edge gap).
   The figcaption is pulled back into the constrained content column and left-
   aligned to it, so only the image runs edge to edge, never the caption. */
.cs-figure--bleed { margin-inline: 0; }
.cs-figure--bleed figcaption {
  margin-inline-start: max(var(--gutter), calc((100% - var(--max-width)) / 2 + var(--gutter)));
  margin-inline-end: var(--gutter);
}

/* Caption for a group of figures (a single image uses figcaption instead). */
.cs-note {
  margin-top: var(--space-md);
  font-size: var(--size-label);
  color: var(--muted);
  max-width: var(--measure);
}

/* Two-up grid for the squarer images; stacks on mobile. */
.cs-figure-grid {
  margin-top: var(--space-xl);
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md);
}
@media (min-width: 48rem) {
  .cs-figure-grid { grid-template-columns: 1fr 1fr; }
}
.cs-figure-grid .cs-figure { margin-top: 0; }

/* Impact: large numerals, generous spacing. Stacked within the reading column
   rather than spread three-across — the numerals are too big to sit side by side
   at this measure, and a vertical run keeps them aligned with the rest of the
   copy while still reading boldly. */
.cs-stats {
  margin-top: var(--space-xl);
  max-width: var(--cs-measure);
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-lg);
  border-top: 1px solid var(--line);
  padding-top: var(--space-xl);
}

.cs-stat__number {
  display: block;
  font-weight: 900;
  font-size: clamp(2.75rem, 5vw + 1rem, 4.5rem);
  line-height: 1;
  letter-spacing: -0.03em;
}
.cs-stat__label {
  display: block;
  margin-top: var(--space-sm);
  color: var(--muted);
  font-size: var(--size-lede);
}

/* The fourth impact point has no numeral — give it its own emphasis. */
.cs-stats-note {
  margin-top: var(--space-xl);
  padding-top: var(--space-lg);
  border-top: 1px solid var(--line);
  font-weight: 700;
  font-size: clamp(1.25rem, 1.5vw + 1rem, 1.75rem);
  max-width: var(--cs-measure);
}

/* --------------------------------------------------------------------------
   10. Footer
   -------------------------------------------------------------------------- */
.site-footer { border-top: 1px solid var(--line); }

/* Carries the gap the intro paragraph used to provide, now that the heading
   sits directly above the contact grid. */
.footer__heading { margin-bottom: var(--space-lg); }

/* auto-fit rather than a fixed column count: one contact column today, and it
   lays out sensibly if another is ever added back. */
.footer__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(0, 20rem));
  gap: var(--space-lg);
}

.footer__col p { margin-top: var(--space-xs); font-size: var(--size-lede); }

/* --- Copy-email button -----------------------------------------------------
   Looks like the old underlined email link, but it's a button that flips
   (a vertical card turn) between the address and "Click to copy to clipboard"
   on hover/focus, and holds on "Copied to clipboard" after a click. The panes
   are position-stacked with backface-visibility, so the flip is pure
   compositor work. Under reduced motion the global rule zeroes the transition,
   leaving an instant text swap — the copying itself is untouched. */
.email-copy {
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;   /* buttons centre their text by default; both panes stay left-aligned */
  cursor: pointer;
  perspective: 600px;
}
.email-copy:hover { color: var(--muted); }

.email-copy__panes {
  display: block;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.35s ease;
}
.email-copy__front,
.email-copy__back {
  display: block;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  white-space: nowrap;
}
.email-copy__back {
  position: absolute;
  inset: 0;
  transform: rotateX(180deg);
}
.email-copy:hover .email-copy__panes,
.email-copy:focus-visible .email-copy__panes,
.email-copy[data-copied="true"] .email-copy__panes {
  transform: rotateX(180deg);
}

/* Copyright left, local-time readout right. Wraps to two stacked lines on
   narrow screens rather than letting the clock collide with the copyright. */
.footer__meta {
  margin-top: var(--space-2xl);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-xs) var(--space-md);
}

.footer__legal,
.footer__credit,
.footer__clock {
  color: var(--muted);
  font-size: var(--size-label);
}

/* Tabular figures give every digit the same advance width, so the milliseconds
   can churn without the text jittering or nudging the copyright line. */
.footer__time {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}

/* --------------------------------------------------------------------------
   11. Visuals page — bento/mosaic grid

   A deliberate exception to the rest of the site's calm single-column list.
   Everything here is namespaced .visuals-* so this grid can't leak into the
   homepage or the case study template. Colour and type still come from the
   shared tokens — only the layout differs.

   Square cells: the wrapper is a query container, so --cell can be derived
   from the width the grid actually has. Without this, a 2x2 span would size
   itself from its content rather than staying square.

   Tile sizes are assigned per image, not computed — three types (large 2x2,
   wide 2x1, small 1x1): 7 large + 10 wide + 12 small = 60 units. 60 divides
   evenly by 6/5/4/3/2, so every column count yields whole rows.

   grid-auto-flow: dense backfills gaps, so the same 29 tiles pack tight at any
   column count. The tile *sizes* are still hand-authored per image (the bento
   character); dense only changes placement order to fill holes — necessary
   because one fixed DOM order can't pack perfectly at six different column
   counts at once.

   Full-bleed: the wrapper drops the site .container, so the grid runs edge to
   edge with only an 8px gutter (matching the gap) instead of the site gutter.
   -------------------------------------------------------------------------- */
/* Page title sits in the normal content column, above the full-bleed grid. */
.visuals__title { margin-bottom: var(--space-lg); }

.visuals {
  container-type: inline-size;
  padding-inline: 8px;         /* small, consistent gutter; otherwise edge-to-edge */
}

.visuals__grid {
  --cols: 6;
  --gap: 8px;
  --cell: calc((100cqw - (var(--cols) - 1) * var(--gap)) / var(--cols));
  display: grid;
  grid-template-columns: repeat(var(--cols), var(--cell));
  grid-auto-rows: var(--cell);
  grid-auto-flow: dense;
  gap: var(--gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

.visuals__tile {
  position: relative;
  overflow: hidden;            /* clips the hover zoom */
  background: var(--panel);
}

.visuals__tile--l { grid-column: span 2; grid-row: span 2; }
.visuals__tile--w { grid-column: span 2; }
/* .visuals__tile--s is the 1x1 default */

.visuals__tile img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;           /* density and rhythm over uncropped fidelity */
  transition: transform 0.25s ease;
}

.visuals__tile:hover img,
.visuals__tile:focus-visible img { transform: scale(1.04); }

@media (prefers-reduced-motion: reduce) {
  .visuals__tile:hover img,
  .visuals__tile:focus-visible img { transform: none; }
}

/* Column ramp: 6 across on large screens, stepping down so tiles never get too
   small to read on the way to phone.

   EVEN counts only (6, 4, 2). The mosaic contains 2x2 tiles, and a 2-wide span
   cannot tile an odd-width grid without leaving holes no matter the DOM order
   or dense packing — verified: 5 and 3 columns both leave gaps, 6/4/2 pack
   perfectly. 6/4/2 still satisfy the target density at every size. */
@media (max-width: 75rem) { .visuals__grid { --cols: 4; } }  /* ~1200px: tablet landscape / small laptop */
@media (max-width: 44rem) { .visuals__grid { --cols: 2; } }  /* ~704px: large phone / small tablet */

/* One column: square cells stop making sense, so rows size to each tile's own
   aspect ratio. Keeps wide-vs-square variation rather than flattening to a
   uniform stack. */
@media (max-width: 22rem) {
  .visuals__grid {
    --cols: 1;
    grid-template-columns: 1fr;
    grid-auto-rows: auto;
  }
  .visuals__tile { grid-column: auto; grid-row: auto; aspect-ratio: 1 / 1; }
  .visuals__tile--w { aspect-ratio: 2 / 1; }
}
