/* Reset and Base Styles */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html {
    scroll-behavior: smooth;
    overflow-y: scroll;
}

body {
    font-family: 'Quicksand', sans-serif;
    color: #2D2D2D;
    line-height: 1.6;
    overflow-x: hidden;
}

h1, h2 {
    font-family: 'Lora', serif;
}

h3 {
    font-family: 'Quicksand', sans-serif;
    color: #2D2D2d;
    font-weight: 500;
}

/* Side Navigation */
.side-nav {
    position: fixed;
    /* env() falls back to 0px on non-notched devices/browsers, so this is a no-op there —
       only matters once viewport-fit=cover (see index.html) lets the page bleed under the
       status bar/notch, which would otherwise pull this same fixed offset in from the true
       screen edge instead of the safe content area. */
    top: calc(2rem + env(safe-area-inset-top, 0px));
    left: calc(2.5rem + env(safe-area-inset-left, 0px)); /* 40px page margin */
    z-index: 1000;
    display: flex;
    flex-direction: column;
    background: transparent;
    padding: 24px 12px 12px 12px; /* 24px top, 12px right, 12px bottom, 12px left */
    border-radius: 8px;
    transition: all 0.3s ease;
}

.side-nav:hover,
.side-nav.nav-expanded {
    background: transparent;
}

/* Nav color variants */
.side-nav.light {
    color: white;
}

.side-nav.dark {
    color: #056AE0;
}

.side-nav.dark .nav-text {
    color: #2D2D2D;
}

.side-nav.dark .brand-text {
    color: #2D2D2D;
}

.nav-brand {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 32px;
    font-weight: 500;
    font-size: 32px;
    white-space: nowrap;
    overflow: hidden;
    color: inherit;
    text-decoration: none;
    padding: 4px 8px; /* 4px vertical, 8px horizontal */
    border-radius: 6px;
    transition: all 0.3s ease;
}

.nav-brand:hover {
    background: transparent; /* No hover state for brand */
}

.side-nav.dark .nav-brand:hover {
    background: transparent; /* No hover state for brand */
}

.brand-icon {
    flex-shrink: 0;
    width: 40px;
    height: 40px;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
}

/* Brand label joins the same stagger sequence as the nav-links labels below (see the
   .nav-links li:nth-child --i rules) as item 0 — first to slide in on entrance, last
   to slide out on exit. Unscoped to #sideNav vs .nav-overlay for the same reason. */
.nav-brand .brand-text { --i: 0; }

.brand-text {
    opacity: 0;
    transform: translateX(-8px);
    transition: opacity 0.2s ease, transform 0.2s ease;
    /* The "+ 0.1s" matches .nav-text's own collapse-grace delay (see .nav-text below) —
       without it, brand's exit delay (0.15s at --i:0) sits BELOW About's (0.1s + 0.1s =
       0.2s at --i:1), so brand would start fading out before About despite being meant to
       exit last. Keeping the same +0.1s here preserves "first in, last out": entrance
       stays Brand → About → Work → Play, exit correctly reverses to
       Play → Work → About → Brand. */
    transition-delay: calc((3 - var(--i, 0)) * 0.05s + 0.1s);
}

.side-nav:hover .brand-text,
.side-nav.nav-expanded .brand-text {
    opacity: 1;
    transform: translateX(0);
    transition-delay: calc(var(--i, 0) * 0.05s);
}

/* Resize guard (class toggled in script.js) — a native OS-level window-edge drag freezes
   the page's JS for the whole drag, then does one layout+paint with the new geometry the
   instant you release, before the resize-driven clip-path recompute has run. That single
   frame can briefly show the base (permanently .dark) nav's real label unmasked at the
   stale clip boundary. Rather than race to recompute the correct boundary before that
   frame paints, just hide every label outright — real and overlay copies alike — for the
   duration of any resize, so there's nothing wrong to see regardless of which frame wins.
   !important because this needs to beat both the resting and hover/expanded opacity rules
   above; transition: none so the hide/reveal itself is an instant cut, not a fade that
   could look like its own flash. */
html.is-resizing .nav-text,
html.is-resizing .brand-text {
    opacity: 0 !important;
    max-width: 0 !important;
    transition: none !important;
}

/* Mobile/tablet-only clone of .nav-brand's own icon+text, used for its section-matched
   color switching there (see the ≤1024px override, and script.js) — desktop doesn't need
   it, since it already gets the same smooth switching for free via the whole-nav
   .nav-overlay clone, which still contains its own copy of .nav-brand. */
.brand-overlay {
    display: none;
}

.nav-links {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 24px;
    position: relative; /* positioning context for .nav-underline, see below */
}

.nav-link {
    display: flex;
    align-items: center;
    gap: 0; /* No gap in collapsed state */
    color: inherit;
    text-decoration: none;
    font-size: 24px;
    font-weight: 400;
    padding: 4px 8px; /* 4px vertical, 8px horizontal */
    border-radius: 8px;
    /* The 0.1s delay only ever applies on the way back to THIS (collapsed) state — a
       transition's delay/duration/timing come from whichever rule matches the state being
       transitioned TO, so the hover/expanded rule below re-zeroes it for the opposite
       direction. Gives the pill a "hair" of grace after the mouse actually leaves before it
       visibly starts collapsing, so a brief overshoot past the nav's edge doesn't read as a
       flicker. Matches the same +0.1s added to .nav-text's exit transition-delay below, so
       label and pill still collapse in sync. */
    transition: all 0.3s 0.1s ease;
    white-space: nowrap;
    overflow: visible;
    position: relative;
    width: fit-content; /* Hug content in collapsed state */
}

/* When nav is expanded (hovered, or briefly on page load — see .nav-expanded), add gap;
   hug icon + label (8px left / 16px right) instead of filling the nav container */
.side-nav:hover .nav-link,
.side-nav.nav-expanded .nav-link {
    gap: 12px;
    width: fit-content;
    padding: 4px 16px 4px 8px;
    /* Re-zeroes the resting rule's 0.1s collapse delay above so expanding stays instant —
       the grace period is only for collapsing back out. */
    transition-delay: 0s;
}

.nav-link:hover {
    background: #0B60E0;
}

.side-nav.dark .nav-link:hover {
    background: #F4F4F4;
}

/* Base nav is pinned .dark, but each link's hover pill still needs to read as the "white
   nav on blue" color whenever the clip-path overlay's white layer is the one currently
   visible *at that link's own position* — checked per link (see the nav color-switching
   IIFE in script.js, which toggles this class on each .nav-link every frame) rather than
   once for the whole nav, since the blue/white boundary can cross through the middle of
   the nav itself (e.g. landing between two links) instead of sitting cleanly above or
   below all of them. #sideNav (ID) beats .side-nav.dark .nav-link:hover's class-only
   specificity. */
#sideNav .nav-link.link-over-blue:hover {
    background: #0B60E0;
}

.side-nav:hover .nav-link.active,
.side-nav.nav-expanded .nav-link.active {
    width: fit-content;
}

/* Selection reads via the underline only — no pill fill or inset shadow */
.nav-link.active {
    background: transparent;
}

.side-nav.dark .nav-link.active {
    background: transparent;
}

/* Active-link underline: a single real element (one per nav copy, see index.html),
   positioned/sized in JS via applyUnderlineState() in script.js (see the ACTIVE-LINK
   UNDERLINE section near the top) rather than derived from the active link's own box via
   ::after left:0/right:0. That derived approach tied the underline's geometry to a parent
   (.nav-link) that's itself mid-transition whenever the nav expands/collapses, which hit a
   Chromium paint-invalidation bug (the pseudo-element visibly "stuck" at a stale size). A
   later version fixed that by having JS track the link's real geometry every frame instead
   — correct, but it meant the underline inherited the link's own not-quite-uniform motion
   (a mid-collapse jump, from .nav-text's deliberately delayed max-width snap) and only
   started reacting once that real, delayed motion began. This version doesn't track the
   link's real-time box at all: JS precomputes each link's two stable target geometries
   (fully collapsed, fully expanded) once up front, and on any expand/collapse just glides
   straight to the right one on its own clean timeline — see UNDERLINE_EXPAND_MS /
   UNDERLINE_COLLAPSE_MS in script.js for why those durations differ. left/width/opacity
   below are just the resting (hidden) initial state and transition durations; JS
   overrides transition-duration and sets left/top/width/opacity inline on every call. */
.nav-underline {
    position: absolute;
    left: 0;
    top: 0;
    width: 0;
    height: 2px;
    border-radius: 999px;
    background: white; /* light nav (white text) */
    opacity: 0;
    pointer-events: none;
    transition: left 0.3s ease, width 0.3s ease, opacity 0.2s ease;
}

.side-nav.dark .nav-underline {
    background: #056AE0; /* dark nav (primary blue text) */
}

/* An active link is already communicated by its underline — a hover pill on top of it
   is redundant/conflicting. !important because several context-specific hover rules
   (.dark, .link-over-blue, mobile .is-open variants) all need to be overridden uniformly. */
.nav-link.active:hover {
    background: transparent !important;
}

.nav-link.active .nav-text {
    font-weight: 500;
}

.nav-icon {
    flex-shrink: 0;
    width: 24px;
    height: 24px;
    /* Icon sits in a 40x40 container */
    padding: 8px;
    box-sizing: content-box;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
}

/* Brand icon still swaps hard via display:none/block (see the .brand-icon rules below) —
   it has no hover/selected microinteraction, just the blue/white swap. */
.brand-icon svg {
    position: absolute;
    display: none;
}

/* Nav-link icons (About/Work/Play) stack pose layers that crossfade via opacity instead
   (see .nav-icon--smiley/--tilt/--rocket further down), so every layer stays visible here
   — inset:0 is what stacks them exactly on top of one another. Color comes from
   currentColor, inherited from .side-nav.dark/.light (same pattern as .nav-hamburger/
   .nav-close), so no separate blue/white copies are needed like the old swap used. */
.nav-icon svg {
    position: absolute;
    inset: 0;
}

/* .brand-icon has no padding, so its content box and padding box are the same size —
   percentage sizing (against a positioned ancestor's padding box, per spec, for an
   absolutely positioned child) safely scales the icon 1:1 with the container, matching it
   across breakpoints (40x40 desktop, 30x30 tablet/mobile) without hardcoding either value
   here. */
.brand-icon svg {
    width: 100%;
    height: 100%;
}

/* .nav-icon, unlike .brand-icon, keeps an 8px padding at this (desktop) breakpoint — sized
   explicitly to its 24x24 content box instead of width:100%, which would resolve against
   the padding box (40x40) and render the icon visibly oversized. Re-declared in px at each
   breakpoint that resizes .nav-icon itself (see .side-nav.is-open .nav-icon further down)
   rather than reused as a percentage, so it never depends on padding staying in sync. */
.nav-icon svg {
    width: 24px;
    height: 24px;
}

/* Show blue icons on dark nav (light backgrounds) by default */
.side-nav.dark .brand-icon .icon-blue {
    display: block;
}

/* Show white icons on light nav (dark backgrounds) by default */
.side-nav.light .brand-icon .icon-white {
    display: block;
}

/* --------------------------------------------------------------------------
   Nav icon microinteractions (About/Work/Play)
   --------------------------------------------------------------------------
   Icon-only animations, colored via currentColor (see .nav-icon svg above). Two
   states drive everything below:

     .nav-link:hover        pure CSS — the whole pill is the hover target, not just
                             the small icon, since that's the real hit area here.
     .nav-link.active       reuses the existing active-link tracking (see
                             applyActiveSection()/syncActiveState() in script.js)
                             instead of a separately-toggled .is-selected class.

   Active-rules are declared after the hover rules on purpose: both end up at equal
   specificity, so on an active link that's also (still) hovered, source order lets
   the active/selected pose win outright rather than replaying the hover pose.
   -------------------------------------------------------------------------- */

/* Each pose's artwork sits further from its own viewBox edges than the old icon set did
   (needed so poses like the smiley squish or the tilt lean have room to move within the
   box), which reads as noticeably smaller at the same 24px render size. Sized up here
   (measured via getBBox() against the old artwork, not eyeballed) to match the old set's
   visual weight, then centered within .nav-icon's full 40x40 padding box — NOT its 24x24
   content box. top/left for an absolutely positioned child resolve against the padding
   edge of its containing block, which sits at the *outer* face of the padding (flush with
   the border, which is 0 here), so 0/0 already lands at .nav-icon's outer 40x40 corner
   before any of this runs; .nav-icon's 8px padding was never part of the offset math. So
   centering a WxW icon here means top:left:(40-W)/2, not (24-W)/2. Same idea at the mobile
   is-open size further down, just against that state's 20x20 box (padding 0 there, so the
   40-vs-24 distinction doesn't apply and the naive (20-W)/2 is already correct). Overrides
   .nav-icon svg's inset/width/height (not just inset) because a shorthand inset alone
   would lose the fight with that rule's explicit width/height: with left, width, and right
   all non-auto the used 'right' gets recalculated to match the explicit width, silently
   cancelling the expansion. */
.nav-icon--smiley svg {
    top: 5.5px;
    left: 5.5px;
    width: 29px;
    height: 29px;
}

.nav-icon--tilt svg {
    top: 4.5px;
    left: 4.5px;
    width: 31px;
    height: 31px;
}

.nav-icon--rocket svg {
    top: 4px;
    left: 4px;
    width: 32px;
    height: 32px;
}

@keyframes navIconFadeOut { 0% { opacity: 1; } 100% { opacity: 0; } }
@keyframes navIconFadeIn  { 0% { opacity: 0; } 100% { opacity: 1; } }

/* 1. Smiley (About) — squishes on hover, fills + bounces on select.
   Layers: .nav-icon__default (resting), .nav-icon__hover (squished),
   .nav-icon__animate (bounce-peak, hover crossfade only), .nav-icon__filled (solid). */

.nav-icon--smiley { transform-origin: 50% 100%; }

.nav-icon--smiley svg {
    opacity: 0;
    transition: opacity .15s ease;
}

.nav-icon--smiley .nav-icon__default { opacity: 1; }

/* :not(.active) rather than relying on the .active rules below to win the cascade — an
   active link's own hover pill is already suppressed the same way (see
   .nav-link.active:hover further up), and this keeps that "no hover feedback once
   selected" behavior explicit instead of depending on which rule happens to be declared
   later (the overlay's :has()-based mirror further down needs the same guard, since an ID
   inside :has() would otherwise out-specificity .nav-link.active regardless of order). */
.nav-link:hover:not(.active) .nav-icon--smiley .nav-icon__default { opacity: 0; }
.nav-link:hover:not(.active) .nav-icon--smiley .nav-icon__hover { opacity: 1; }

.nav-link.active .nav-icon--smiley {
    animation: navIconBounce .5s ease;
}
.nav-link.active .nav-icon--smiley .nav-icon__default,
.nav-link.active .nav-icon--smiley .nav-icon__hover,
.nav-link.active .nav-icon--smiley .nav-icon__animate {
    opacity: 0;
}
.nav-link.active .nav-icon--smiley .nav-icon__filled {
    opacity: 1;
}

@keyframes navIconBounce {
    0%   { transform: translateY(0) scale(1); }
    30%  { transform: translateY(-4px) scale(1); }
    55%  { transform: translateY(0) scaleY(.8) scaleX(1.12); }
    75%  { transform: translateY(0) scaleY(1.06) scaleX(.96); }
    100% { transform: translateY(0) scale(1); }
}

/* 2. Tilt/diamond (Work) — leans on its base on hover, snaps upright + fills on select.
   Layers: .nav-icon__outline (stroke), .nav-icon__filled (solid). Both share one artwork
   rotated purely in CSS, anchored at the diamond's bottom point rather than its center —
   that's what makes it read as a tilt instead of a spin. */

.nav-icon--tilt .nav-icon__outline,
.nav-icon--tilt .nav-icon__filled {
    transform-origin: 44% 80%;
}

.nav-icon--tilt .nav-icon__outline {
    transition: transform .25s ease;
}
.nav-link:hover:not(.active) .nav-icon--tilt .nav-icon__outline {
    transform: rotate(8deg);
}

.nav-icon--tilt .nav-icon__filled {
    opacity: 0;
}

.nav-link.active .nav-icon--tilt .nav-icon__outline {
    animation:
        navIconSnapUpright .55s cubic-bezier(.3,.7,.4,1) forwards,
        navIconFadeOut .15s ease forwards;
}
.nav-link.active .nav-icon--tilt .nav-icon__filled {
    animation:
        navIconSnapUpright .55s cubic-bezier(.3,.7,.4,1) forwards,
        navIconFadeIn .15s ease forwards;
}

@keyframes navIconSnapUpright {
    0%   { transform: rotate(8deg); }
    30%  { transform: rotate(-14deg); }
    60%  { transform: rotate(5deg); }
    80%  { transform: rotate(-2deg); }
    100% { transform: rotate(0deg); }
}

/* 3. Rocket (Play) — settles back on hover, fills + flame ignites + ascends on select.
   Layers: .nav-icon__flame (solid, static — doesn't move with the rocket),
   .nav-icon__outline (stroke), .nav-icon__filled (solid). */

.nav-icon--rocket .nav-icon__outline {
    transition: transform .25s ease;
}
.nav-link:hover:not(.active) .nav-icon--rocket .nav-icon__outline {
    transform: translate(-1px, 1px);
}

.nav-icon--rocket .nav-icon__filled { opacity: 0; }
.nav-icon--rocket .nav-icon__flame  { opacity: 0; transform-origin: 60% 15%; }

.nav-link.active .nav-icon--rocket .nav-icon__outline {
    animation:
        navIconAscend .5s cubic-bezier(.16,.84,.44,1) forwards,
        navIconFadeOut .15s ease forwards;
}
.nav-link.active .nav-icon--rocket .nav-icon__filled {
    animation:
        navIconAscend .5s cubic-bezier(.16,.84,.44,1) forwards,
        navIconFadeIn .15s ease forwards;
}
.nav-link.active .nav-icon--rocket .nav-icon__flame {
    animation:
        navIconFlameIgnite .15s ease forwards,
        navIconFlicker .35s ease-in-out .15s forwards;
}

@keyframes navIconAscend {
    0%   { transform: translate(-1px, 1px); }
    100% { transform: translate(3px, -2px); }
}
@keyframes navIconFlameIgnite {
    0%   { opacity: 0; transform: scale(.8); }
    100% { opacity: 1; transform: scale(1); }
}
@keyframes navIconFlicker {
    0%   { opacity: 1; transform: scale(1, 1); }
    25%  { opacity: .85; transform: scale(.93, 1.08); }
    50%  { opacity: 1; transform: scale(1.06, .94); }
    75%  { opacity: .9; transform: scale(.96, 1.04); }
    100% { opacity: 1; transform: scale(1, 1); }
}

/* Respect reduced-motion: drop straight to the selected end state instead of playing the
   bounce / tilt / ascend. */
@media (prefers-reduced-motion: reduce) {
    .nav-link.active .nav-icon--smiley,
    .nav-link.active .nav-icon--tilt .nav-icon__outline,
    .nav-link.active .nav-icon--tilt .nav-icon__filled,
    .nav-link.active .nav-icon--rocket .nav-icon__outline,
    .nav-link.active .nav-icon--rocket .nav-icon__filled,
    .nav-link.active .nav-icon--rocket .nav-icon__flame {
        animation: none;
    }
    .nav-link.active .nav-icon--smiley .nav-icon__default,
    .nav-link.active .nav-icon--smiley .nav-icon__hover,
    .nav-link.active .nav-icon--smiley .nav-icon__animate,
    .nav-link.active .nav-icon--tilt .nav-icon__outline,
    .nav-link.active .nav-icon--rocket .nav-icon__outline {
        opacity: 0;
    }
    .nav-link.active .nav-icon--smiley .nav-icon__filled,
    .nav-link.active .nav-icon--tilt .nav-icon__filled,
    .nav-link.active .nav-icon--rocket .nav-icon__filled,
    .nav-link.active .nav-icon--rocket .nav-icon__flame {
        opacity: 1;
    }
    .nav-link.active .nav-icon--tilt .nav-icon__filled { transform: rotate(0deg); }
    .nav-link.active .nav-icon--rocket .nav-icon__filled { transform: translate(3px, -2px); }
}

/* Per-link index, used below to stagger the label's slide/fade — entrance cascades
   top-down (Tanner brand label first — see its own --i:0 rule near .brand-text —
   then About, Work, Play), exit reverses it (Play first, brand label last). Matches
   both nav copies: unscoped to #sideNav vs .nav-overlay on purpose, since the overlay
   clone has the identical li structure and needs the exact same stagger. */
.nav-links li:nth-child(1) .nav-text { --i: 1; }
.nav-links li:nth-child(2) .nav-text { --i: 2; }
.nav-links li:nth-child(3) .nav-text { --i: 3; }

.nav-text {
    opacity: 0;
    transform: translateX(-8px);
    color: inherit;
    max-width: 0;
    overflow: hidden;
    /* max-width (not width) so both endpoints are concrete lengths, not the `auto`
       keyword — Chromium doesn't honor transition-delay on a width transition that
       involves `auto` at either end, it just snaps instantly regardless of the delay
       specified. 200px is just a generous cap; the label still renders at its own
       natural (smaller) intrinsic width once expanded, max-width only clips it while
       collapsed. Zero duration since this is a step, not a fade, but it still needs
       its own delay: without one it snaps shut instantly on exit, and since
       overflow:hidden clips at that width, every label would vanish in the same frame
       regardless of their staggered opacity/transform delays. Its delay here is offset
       by the fade's own duration so the box only collapses once that item has actually
       finished fading out. Order matches transition-property: opacity, transform,
       max-width. */
    transition-property: opacity, transform, max-width;
    transition-duration: 0.2s, 0.2s, 0s;
    transition-timing-function: ease, ease, ease;
    /* Resting/collapsed state's delay governs the exit transition (reverse order —
       highest --i, i.e. Play, goes first; the brand label is --i:0, so it's last).
       The expanded-state rules below override this with the entrance order whenever
       they're the active (more specific) match. The "+ 0.2s" on max-width's delay is
       the fade's own duration above — keep these two in sync, or the box will
       collapse before (clipping the still-visible fade) or noticeably after (a stray
       gap) the label actually finishes disappearing. The other "+ 0.1s" is the same
       collapse grace period added to .nav-link's own transition-delay above — kept in
       sync here too, so the label doesn't start fading out before the pill itself has
       even begun collapsing. */
    transition-delay:
        calc((3 - var(--i, 0)) * 0.05s + 0.1s),
        calc((3 - var(--i, 0)) * 0.05s + 0.1s),
        calc((3 - var(--i, 0)) * 0.05s + 0.1s + 0.2s);
}

.side-nav:hover .nav-text,
.side-nav.nav-expanded .nav-text {
    opacity: 1;
    transform: translateX(0);
    max-width: 200px;
    /* max-width opens immediately (no delay) so the box is full-size before its
       fade-in even starts — nothing to clip on the way in. Order matches
       transition-property on the resting rule above: opacity, transform, max-width. */
    transition-delay:
        calc(var(--i, 0) * 0.05s),
        calc(var(--i, 0) * 0.05s),
        0s;
}

/* --- Nav color switching via clip-path ---
   Two full copies of the nav sit stacked at the identical fixed position: the real,
   interactive "base" copy (#sideNav, permanently .dark/blue) always renders; a decorative
   "overlay" clone (permanently .light/white, inserted by script.js) sits on top, clipped
   with a live clip-path to only show over the page's blue region. Where the overlay is
   visible it fully repaints over the base copy beneath it, giving a hard, pixel-accurate
   split instead of a class-based swap or an eased blend. Both copies get all their color
   (text, icons, hover pill, active underline) from the ordinary .dark/.light rules above —
   nothing here overrides those; each copy's class is just pinned rather than scroll-driven. */
.nav-overlay {
    pointer-events: none;
    /* Fully visible by default, matching the page's actual top-of-load state (hero
       section, blue background, white nav needed) — safe if the geometry script can't
       run. JS clips this from the bottom once it measures the real split. */
    clip-path: inset(0px 0px 0px 0px);
    /* .side-nav sets `transition: all 0.3s ease` for its hover expand/collapse animation.
       The overlay is a clone, so it inherits that — which was silently easing every
       clip-path update over 300ms, the "delay" noticeable when scrolling quickly. This
       needs to be a hard, instant split. */
    transition: none;
}

.nav-overlay[aria-hidden] * {
    pointer-events: none;
}

/* Base nav's real :hover expands it; mirror that onto the overlay (which can never
   receive a genuine :hover of its own, since pointer-events is off) via :has() rather
   than duplicating the hover state in JS. Same for .nav-expanded, the synthetic
   briefly-open-on-load state toggled in script.js. */
body:has(#sideNav:hover) .nav-overlay .nav-link,
body:has(#sideNav.nav-expanded) .nav-overlay .nav-link {
    gap: 12px;
    width: fit-content;
    padding: 4px 16px 4px 8px;
    /* Same re-zero as the base nav's own .side-nav:hover/.nav-expanded .nav-link rule —
       without it this mirror falls back to the resting .nav-link rule's 0.1s collapse
       delay in BOTH directions, so the overlay would visibly lag 0.1s behind the real
       nav on every expand. */
    transition-delay: 0s;
}

body:has(#sideNav:hover) .nav-overlay .nav-text,
body:has(#sideNav.nav-expanded) .nav-overlay .nav-text {
    opacity: 1;
    transform: translateX(0);
    max-width: 200px;
    transition-delay:
        calc(var(--i, 0) * 0.05s),
        calc(var(--i, 0) * 0.05s),
        0s;
}

body:has(#sideNav:hover) .nav-overlay .brand-text,
body:has(#sideNav.nav-expanded) .nav-overlay .brand-text {
    opacity: 1;
    transform: translateX(0);
    transition-delay: calc(var(--i, 0) * 0.05s);
}

/* Same :has() mirroring, one level more specific — the nav-icon hover microinteractions
   (see .nav-icon--smiley/--tilt/--rocket further up) key off `.nav-link:hover:not(.active)`,
   which the overlay's own links can never match (pointer-events: none, same reason as
   above). Each icon-set class is unique to its own link, so
   :has(#sideNav .nav-link:hover:not(.active) .nav-icon--x) reliably means "the [x] link is
   hovered and isn't the selected one" without needing to also match data-section. The
   :not(.active) here is required, not just consistency with the rule above: an ID inside
   :has() (#sideNav) makes this whole selector far more specific than .nav-link.active ever
   is, so without it this mirror would keep forcing the hover pose over the selected pose
   any time the active link was also hovered — regardless of source order. The
   .active/selected poses don't need an equivalent mirror rule at all: syncActiveState() in
   script.js already copies the real .active class onto the matching overlay link
   directly. */
body:has(#sideNav .nav-link:hover:not(.active) .nav-icon--smiley) .nav-overlay .nav-icon--smiley .nav-icon__default {
    opacity: 0;
}
body:has(#sideNav .nav-link:hover:not(.active) .nav-icon--smiley) .nav-overlay .nav-icon--smiley .nav-icon__hover {
    opacity: 1;
}

body:has(#sideNav .nav-link:hover:not(.active) .nav-icon--tilt) .nav-overlay .nav-icon--tilt .nav-icon__outline {
    transform: rotate(8deg);
}

body:has(#sideNav .nav-link:hover:not(.active) .nav-icon--rocket) .nav-overlay .nav-icon--rocket .nav-icon__outline {
    transform: translate(-1px, 1px);
}

/* Hidden on desktop — media query overrides these */
.nav-hamburger,
.nav-close,
.nav-divider {
    display: none;
}

/* Scroll Wrapper */
.scroll-wrapper {
    height: 100vh;
    overflow-y: scroll;
    /* Page-level scrolling is vertical-only by design. Without this, small overshoots
       in edge-to-edge breakout math (100vw doesn't subtract this element's own
       permanent scrollbar — see .play-carousel's margin-right) leak out as a few
       pixels of real horizontal scroll. .play-scroll's own nested overflow-x:auto
       carousel is unaffected — it's a separate, inner scroll container. */
    overflow-x: hidden;
}

/* Sections */
.section {
    min-height: 100vh;
    position: relative;
    /* Page margins: 40px left, 80px right */
    padding-left: 2.5rem;  /* 40px */
    padding-right: 5rem;   /* 80px */
}

/* Hero Section */
.hero-section {
    background: #056AE0;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 2rem;
    position: relative;
    overflow: hidden;
}

/* Caps the hero's content (image, lines, text) at the 1440px design width so their
   percentage/edge-anchored positions stay in sync with each other on wider screens —
   otherwise .hero-lines (anchored to the right edge) and .hero-headshot (anchored via a
   left percentage) drift apart as the viewport grows past the design width. Beyond
   1440px this just centers the same 1440px layout in extra blue background, matching
   how the section already renders at exactly 1440px.
   Positioned absolute (inset 0), not as a stretched flex item, so it ignores
   .hero-section's own 2rem padding the same way .hero-lines/.hero-headshot (also
   absolute) always have — a flex item is laid out inside the flex container's content
   box (inset by that padding), which was pulling the lines/headshot in from the true
   viewport edges instead of sitting flush against them. */
.hero-inner {
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    max-width: 1440px;
    margin: 0 auto;
    display: flex;
    align-items: center;
}

.hero-lines {
    position: absolute;
    right: 0;
    top: 50%;
    transform: translateY(-50%);
    height: 100%;
    width: auto;
    z-index: 1;
    pointer-events: none;
}

/* Hero headshot positioning */
.hero-headshot {
    position: absolute;
    left: 5%; /* 158/1440 = 10.97% */
    bottom: 0;
    width: 50%; /* 870/1440 = 60.42% */
    height: auto;
    z-index: 2; /* Sits above gradient lines (background is z-index 0) */
}

.hero-content {
    position: relative;
    z-index: 3; /* Above the headshot */
    text-align: right; /* Align text to the right */
    margin-left: auto; /* Push to the right side */
    margin-right: 20%; /* Some spacing from the right edge */
    max-width: 600px;
    /* .hero-content is the sole flex item in .hero-inner, with ample free space around it —
       it should never need to shrink. Some browsers (seen in Arc, not Chrome) compute the
       flex auto-minimum-size differently and were shrinking it below its one-line natural
       width anyway, wrapping "Tanner" onto its own line. flex-shrink:0 makes it render at
       its natural size everywhere, regardless of that engine difference. Reset back to
       shrinkable in the mobile media query below, where wrapping onto multiple lines is
       intentional. */
    flex-shrink: 0;
}

.hero-title {
    font-size: 3rem;
    color: white;
    font-weight: 400;
    white-space: nowrap; /* single-line headline by design; reset in the mobile media query */
}

/* Hero title underline: matches the .section-title::after pattern (#F5A623), scoped to
   just "Tanner" and drawn in left-to-right after the hero-lines SVG animation finishes
   (last line: 0.4s delay + 1.4s duration = 1.8s, plus a short 0.05s buffer). Duration and
   easing match the lines' actual draw speed rather than reusing their duration: line-5's
   path is ~1773 units drawn over 1.4s (~1266 units/sec); "Tanner" is only ~160px wide, so
   at that same rate it draws in ~0.13s (tuned up slightly to 0.18s to feel less instant).
   Same cubic-bezier as the lines for a consistent motion feel. */
.hero-title-highlight {
    position: relative;
    display: inline-block;
}

.hero-title-highlight::after {
    content: '';
    position: absolute;
    left: 0;
    bottom: 0px;
    width: 100%;
    height: 2px;
    background: #F5A623;
    border-radius: 999px;
    transform: scaleX(0);
    transform-origin: left;
    animation: heroTitleUnderline 0.18s cubic-bezier(0.4, 0, 0.2, 1) 1.85s forwards;
}

@keyframes heroTitleUnderline {
    to {
        transform: scaleX(1);
    }
}

/* Matches the site's tablet breakpoint (≤1024px, same as the nav collapse and the
   full-width content switch above) rather than a one-off threshold of its own. */
@media (max-width: 1024px) {
    .hero-headshot {
        display: none;
    }

    /* Parked: none of the sizing approaches tried here (scaling, capping, fixed-size
       cropping) behaved as expected on tablet/mobile — hiding the lines for now rather
       than shipping a half-right version. Revisit later. */
    .hero-lines {
        display: none;
    }
}

/* Section Containers */
.section-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 6rem 2rem;
    /* Offset: 40px (page margin) + 197px (expanded nav) + 24px (gap) = 261px = 16.3125rem */
    padding-left: 12.5rem;
}

.section-title {
    font-size: 2rem;
    margin-bottom: 2rem;
    color: #333;
    position: relative;
    padding-bottom: .25rem;
    font-weight: 400;
}

.section-title::after {
    content: '';
    position: absolute;
    bottom: 0;
    left: 0;
    width: 7.5rem; /* 120px */
    height: 2px;
    background: #F5A623;
}

/* About Section */
.about-section {
    background: white;
    color: white;
    overflow: hidden;
}

/* width:auto (not 100%) locks the curve's scale to section height alone via its
   intrinsic viewBox aspect ratio — a viewport-width change can never stretch or
   reflow it. The path's fill extends well past the 1440-wide viewBox on both
   sides so this height-driven box still covers ultra-wide viewports without
   gapping; overflow is simply clipped by .about-section's overflow:hidden. */
.about-bg {
    position: absolute;
    top: 0;
    left: 0;
    width: auto;
    height: 100%;
    overflow: visible;
    pointer-events: none;
    transform: translateY(-160px);
}

.about-section .section-container {
    position: relative;
    z-index: 1;
}

.about-section .section-title {
    color: white;
}

.about-section .section-title::after {
    background: #F5A623;
}

.about-text {
    font-size: 1rem;
    line-height: 1.8;
    margin-bottom: 24px;
}

/* Resume/LinkedIn/Email — moved here from the removed Contact section */
.about-links {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
    margin-bottom: 2.5rem;
}

.about-link {
    color: white;
    font-family: 'Quicksand', sans-serif;
    font-size: 14px;
    font-weight: 500;
}

.about-link:hover {
    text-decoration: underline;
}

.about-link-divider {
    width: 1px;
    height: 16px;
    background: #F5A623;
}

.about-image {
    width: 100%;
    height: auto;
    border-radius: 20px;
    box-shadow: 0 8px 32px rgba(0, 10, 20, 0.25);
    display: block;
}

/* Work Section */
.work-section {
    background: #ffffff;
    min-height: auto; /* Allow it to be taller than viewport */
}

.project-card {
    display: grid;
    grid-template-columns: 40% 60%;
    gap: 3rem;
    margin-bottom: 4rem;
    align-items: center;
}

.project-card:last-child {
    /* .work-section's own section-container padding-bottom already provides
       trailing space — this margin was stacking an extra 64px on top of it,
       making the Work→Contact gap inconsistent with the rest of the page's
       192px section-to-section rhythm. */
    margin-bottom: 0;
}

.project-content {
    display: flex;
    flex-direction: column;
}

.project-title {
    font-family: 'Quicksand', sans-serif;
    font-size: 1.5rem;
    font-weight: 400;
    line-height: 1.3;
    margin-bottom: 0.25rem;
    color: #2D2D2D;
}

.project-client {
    color: #74767E;
    font-size: 16px;
    margin-bottom: 1rem;
}

a {
    text-decoration: none;
}

.project-description {
    color: #555;
    line-height: 1.7;
    font-size: 0.95rem;
    margin-bottom: 1.5rem;
}

.project-image {
    display: block;
    text-decoration: none;
    border-radius: 12px;
    overflow: hidden;
    transition: transform 0.3s ease, opacity 0.3s ease;
}

.project-image:hover {
    transform: translateY(-4px);
    opacity: 0.9;
}

.project-image img {
    width: 100%;
    height: auto;
    aspect-ratio: 12 / 5;
    object-fit: cover;
    display: block;
}

/* Play Section */
.play-section {
    background: white;
    min-height: auto;
}

/* Left edge stays aligned with the section's own heading/paragraph, same as every other
   section. The right edge is an experimental breakout past .section-container's right
   padding out to the true viewport edge — width:auto (not 100%) so the negative
   margin-right actually stretches the box rather than just overlapping past it. The
   margin math cancels out three stacked insets between the carousel and the viewport
   edge: .section's own 80px page margin (padding-right: 5rem — see .section), plus
   .section-container's 2rem padding, plus whatever gutter max-width:1200px centering
   leaves beyond both of those. Mobile is a separate case — see the ≤768px breakout
   below — which goes full-bleed on both edges instead. */
.play-carousel {
    position: relative;
    width: auto;
    margin-right: calc(-1 * (7rem + max(0px, (100vw - 1320px) / 2)));
    margin-top: 3rem;
    /* The photos are already edited/cropped to their own intentional aspect ratios —
       unlike the placeholder tiles this replaced, nothing here crops them further.
       --play-row-h is the one shared dimension every column is anchored to; each
       image's width just falls out of (row height × its own ratio), so columns are
       naturally different widths. --play-gap is 12px everywhere — between columns,
       and between the two images within a stacked pair — per the actual grid these
       photos were laid out in.
       Solved for viewport-fit rather than a fixed size: Play is now the last section,
       directly followed by .site-footer with nothing after it, and clicking its nav
       link should land with its heading at the same spot every other section's does
       AND the footer flush against the viewport's bottom edge — i.e. Play + footer
       should exactly fill the viewport from the landed scroll position. Measured
       directly: .play-section's own height tracks --play-row-h 1:1 plus a fixed
       ~422px of everything else in it (heading, intro text, margins/padding), and
       .site-footer is a fixed ~124px tall. So for a viewport height V:
         playHeight + footerHeight = V
         (row-h + 422) + 124 = V
         row-h = V − 546
       calc(100vh − 546px) below is exactly that, solved live per viewport rather than
       baked in for one assumed height. max() with the original 320px floors it on
       very short viewports instead of letting the photos shrink to nothing. */
    --play-row-h: max(calc(100vh - 546px), 320px);
    --play-gap: 12px;
}

/* The actual native-scroll element. Kept separate from .play-carousel so the edge
   fades (siblings of this, not descendants) can be pinned to the carousel's edges —
   an absolutely positioned descendant of a scroll container scrolls along with its
   content, which was carrying the fades away from the edges as soon as the user
   scrolled instead of leaving them anchored in place. */
.play-scroll {
    overflow-x: auto;
    overflow-y: hidden;
    /* html sets scroll-behavior:smooth for anchor-link navigation elsewhere on the
       page — not inherited by default, but explicit here anyway so script.js's
       corrective loop-wrap jump (see the 'scroll' listener there) is always an
       instant snap, never an eased scroll fighting the gesture that triggered it. */
    scroll-behavior: auto;
    /* Scrollable via native touch/trackpad/scrollbar-drag, but the scrollbar itself
       would sit awkwardly under the edge gradients — hidden here (still fully
       functional, just not drawn) rather than styled to match. */
    scrollbar-width: none;
}

.play-scroll::-webkit-scrollbar {
    display: none;
}

/* Auto-drift is a pure CSS/GPU transform animation (compositor-only, no per-frame JS)
   rather than driving scrollLeft — reading/writing scrollLeft every animation frame
   forces a synchronous layout recalculation each time, which is what made the
   scrollLeft-driven version choppy. This runs independently of .play-scroll's native
   scroll position: the transform slides the already-scrolled view an additional
   amount, so auto-drift and manual scrolling never fight over the same coordinate
   system. --play-half-width (exactly half the track's width, since the tile sequence
   is written out twice back to back — see index.html) is set once in script.js;
   animating to a translateX of exactly that value lands on a pixel-identical repeat
   of the start, so the loop back to 0 is seamless. */
.play-track {
    display: flex;
    align-items: flex-start;
    gap: var(--play-gap);
    width: max-content;
    /* Room for .play-photo:hover's translateY(-4px) lift — without it, any image
       flush against the row's top edge (every .bento-full, and the top image of
       each .bento-stack) would get its lifted top edge clipped by .play-scroll's
       overflow-y:hidden, since that hidden overflow is measured from this
       element's own un-lifted box. */
    padding-top: 8px;
    animation: play-drift linear infinite;
    animation-duration: 60s; /* placeholder — script.js sets the real duration from track width */
}

.play-carousel:hover .play-track {
    animation-play-state: paused;
}

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

@keyframes play-drift {
    from {
        transform: translateX(0);
    }
    to {
        transform: translateX(calc(-1 * var(--play-half-width, 0px)));
    }
}

/* A full-height photo: height is the shared anchor, width is whatever that image's
   own aspect ratio implies — no cropping, since the edit/crop was already done. */
.bento-full {
    flex: 0 0 auto;
    height: var(--play-row-h);
}

.bento-full img {
    /* Explicit var(), not a height:100% percentage — Safari/WebKit has a long-standing
       bug where percentage heights on an <img> inside a flex item don't reliably
       resolve against the parent's own height when that height comes from a custom
       property, silently falling back to the image's native intrinsic pixel size
       instead (visible as one giant blown-up photo on real iOS Safari, while Chrome —
       including its device-emulation mode, which is still Blink, not WebKit — renders
       it correctly). .bento-stack img already sidesteps this by using an absolute
       calc() instead of a percentage; matching that here for the same reason. */
    height: var(--play-row-h);
    width: auto;
    border-radius: 16px;
    display: block;
    -webkit-user-drag: none;
    user-select: none;
}

/* A stacked pair: both images together span the same row height as a .bento-full
   neighbor (each gets (row height − gap) / 2, an even split), each at its own
   intrinsic width — so the two images in one stack can render at different widths.
   Centered rather than left/right-aligned so a narrower top or bottom image reads as
   intentional, not like a misaligned column. */
.bento-stack {
    flex: 0 0 auto;
    height: var(--play-row-h);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--play-gap);
}

.bento-stack img {
    flex: 0 0 auto;
    height: calc((var(--play-row-h) - var(--play-gap)) / 2);
    width: auto;
    border-radius: 16px;
    display: block;
    -webkit-user-drag: none;
    user-select: none;
}

/* Edge fades: mask the tiles scrolling in/out, and hide the left one passing behind the
   floating nav, without needing a CSS mask-image (broader browser support, and easy to
   tint to the section's own white background). */
.play-fade {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 24px;
    z-index: 2;
    pointer-events: none;
}

.play-fade-left {
    left: 0;
    background: linear-gradient(to right, white 0%, rgba(255, 255, 255, 0) 100%);
}

.play-fade-right {
    right: 0;
    background: linear-gradient(to left, white 0%, rgba(255, 255, 255, 0) 100%);
}

@media (max-width: 1024px) {
    .play-carousel {
        /* Same viewport-fit formula as the base rule, re-solved for this breakpoint's
           own fixed chrome height (~358px, measured the same way — heading/intro text
           reflow to more lines at this width, changing the constant) and the same
           ~124px footer. 256px (the original static value) floors it. */
        --play-row-h: max(calc(100vh - 482px), 256px);
        /* Full-bleed breakout: width:100vw + negative-margin technique — a deliberate
           departure from the desktop breakpoint, where the carousel stays contained
           (right edge only breaking out — see the base .play-carousel rule above) within the section's
           normal text width. At tablet and mobile widths that contained box is narrow
           enough that it was only fitting a sliver of a single tile at a time (or, at
           tablet, still cost a full tile's worth of trailing whitespace), so both
           breakpoints go full edge-to-edge instead. The edge fades (siblings, not
           descendants — see .play-scroll above) automatically stretch to match, since
           they're positioned relative to this same element.
           .play-section's default overflow-x and body's own overflow-x:hidden are the
           safety nets against this ever widening the page itself. */
        left: 50%;
        right: 50%;
        width: 100vw;
        margin-left: -50vw;
        margin-right: -50vw;
    }
}

@media (max-width: 768px) {
    .play-carousel {
        /* Same formula again, re-solved for mobile's own ~489px fixed chrome height
           and the same ~124px footer. 196px (the original static value) floors it. */
        --play-row-h: max(calc(100vh - 613px), 196px);
    }
}

.section-text {
    max-width: 800px;
    font-size: 1rem;
    line-height: 1.8;
    color: #555;
}

/* Unlike About/Work's prose paragraphs, the Play intro should span the full
   content width of .section-container rather than wrap at the shared 800px cap. */
.play-section .section-text {
    max-width: none;
}

/* Footer — plain white footer, matching the same .project-footer/.footer-links
   pattern the case-study pages use (see project-styles.css), now that the old
   Contact section (and its blue curved background) is gone. */
.site-footer {
    background: white;
    padding: 3rem 2rem 24px;
    text-align: center;
}

.footer-text {
    font-size: 14px;
    color: #74767E;
}

.footer-links {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 12px;
    margin-top: 8px;
}

.footer-links a {
    color: #056AE0;
    font-size: 14px;
}

.footer-links a:hover {
    text-decoration: underline;
}

.footer-link-divider {
    width: 1px;
    height: 16px;
    background: #F5A623;
}

/* Responsive Design */
@media (max-width: 1024px) {
    /* Full-width content: matches the nav's own ≤1024px breakpoint (see the collapsed
       .side-nav rules further down) — the nav collapses to a small fixed icon and content
       is allowed to scroll behind it at that exact width, so there's no need to reserve
       space for it here — just a small, even gutter on both sides. A narrower threshold
       here would strand the old desktop gutter for a width range where the nav has
       already collapsed and no longer needs it reserved. */
    .section {
        padding-left: 1.5rem;  /* 24px */
        padding-right: 1.5rem; /* 24px */
    }

    .section-container {
        padding-left: 1.5rem;
        padding-right: 1.5rem;
    }

    .hero-content {
        flex-direction: column;
        text-align: center;
        margin-left: auto;
        margin-right: auto;
        flex-shrink: 1; /* allow shrinking again so the title can wrap onto multiple lines */
    }

    .hero-title {
        font-size: 2.5rem;
        white-space: normal; /* wrapping is intentional at this size */
    }

    /* .project-card is `display: grid` (two side-by-side columns) at desktop width —
       flex-direction only affects flex containers, so this was silently doing nothing and
       the grid columns stayed in effect on mobile, squeezing the image and text into two
       narrow side-by-side slivers instead of stacking. */
    .project-card {
        display: flex;
        flex-direction: column;
    }

    .project-image {
        width: 100%;
        order: -1; /* image above the title/description, matching the mobile design */
    }

    .section-container {
        /* Reserves clearance under the fixed collapsed nav pill so section content never
           renders underneath it. The pill's own top offset grows by
           env(safe-area-inset-top) on a notched/Dynamic-Island device (see .side-nav
           further down) — this has to grow by the same amount to keep matching it, or a
           real device with a tall inset ends up with less clearance than the pill actually
           needs, letting it overlap the section heading. env() defaults to 0px on
           non-notched devices, so this is a no-op there — it's also why Chrome DevTools'
           device toolbar never reproduces this: it reports env(safe-area-inset-*) as 0px
           even when simulating a notched iPhone, so this gap only ever shows up on real
           hardware. */
        padding-top: calc(4rem + env(safe-area-inset-top, 0px));
        padding-bottom: 4rem;
    }
}

@media (max-width: 768px) {
    /* .section/.section-container's 24px gutter is already set by the ≤1024px block
       above (this range is a subset of it) — only the nav's own distance from the
       viewport edge shrinks further here (see the collapsed .side-nav rule, in the
       ≤1024px block below, which governs nav position at every width down to mobile). */
    .nav-links {
        gap: 0.5rem;
    }

    .hero-title {
        font-size: 2rem;
    }

    .section-title {
        font-size: 1.5rem;
    }

    /* Scale body copy down proportionally on mobile — these otherwise stayed at
       full desktop size and read too large on a phone screen. */
    .about-text {
        font-size: 0.9rem;
    }

    .project-title {
        font-size: 1.25rem;
    }

    .project-client {
        font-size: 14px;
    }

    .project-description {
        font-size: 0.85rem;
    }
}
/* ============================================================
   MOBILE & TABLET NAV (≤1024px)
   ============================================================ */

@media (max-width: 1024px) {

    /* The overlay stays enabled below this breakpoint too — it shares every collapsed-state
       rule below with the base nav (both are just `.side-nav`), so it collapses to the same
       hamburger icon, in the same spot, and the clip-path split still gives that icon
       correct white-on-blue / blue-on-white contrast while scrolling. It's hidden separately,
       once the menu is actually open (see `#sideNav.is-open ~ .nav-overlay` below) since at
       that point the base nav becomes its own full-screen panel and the overlay has nothing
       useful to paint. */

    /* --- Collapsed state --- */

    /* Content is now full-width on tablet/mobile (see the .section/.section-container
       rules above) and scrolls behind the fixed nav icon instead of being pushed clear of
       it. A frosted glass pill — translucent background + blur + shadow — stands in for
       real nav styling for now, just enough to read as elevated above whatever passes
       underneath it. Sized to just the hamburger button now — the logo lives in its own
       separate .nav-brand pill (below), no longer stacked inside this one. */
    .side-nav {
        top: calc(var(--nav-edge-offset) + env(safe-area-inset-top, 0px));
        left: calc(var(--nav-edge-offset) + env(safe-area-inset-left, 0px));
        width: auto;
        /* 12px padding around a 24x24 icon = a 48x48 circle. */
        padding: 12px;
        background: rgba(255, 255, 255, 0.5) !important;
        backdrop-filter: blur(12px);
        -webkit-backdrop-filter: blur(12px);
        border-radius: 999px;
        flex-direction: column;
        align-items: flex-start;
        gap: 12px;
        transition: none;
    }

    /* The drop shadow lives on its own layer, not as this element's own box-shadow —
       box-shadow shares a paint pass with backdrop-filter on the same element, and browsers
       (Safari in particular) can rasterize that combination as a rectangle that pokes out
       past the rounded corners instead of following them. A shadow-only pseudo-element with
       no backdrop-filter of its own doesn't hit that path (confirmed directly against both
       versions in an isolated test) and reliably clips to `border-radius: inherit`, whatever
       shape the real element currently has (999px pill here, 28px card once expanded — see
       .side-nav.is-open::before below). Softened from the original 0.45-alpha version, which
       read as too hard-edged/artificial for a passive resting shadow. */
    .side-nav::before {
        content: '';
        position: absolute;
        inset: 0;
        border-radius: inherit;
        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
        z-index: -1;
        pointer-events: none;
    }

    /* The overlay clone sits at the identical position/size/radius as the base pill
       beneath it, so the base's own ::before above already paints a correct, rounded
       shadow there — no need for the overlay to paint a second one on top. It shouldn't:
       the overlay carries a live, ever-present `clip-path` (for the scroll-driven
       color split), and that combination with a blurred box-shadow on a `border-radius:
       inherit` pseudo hits the same "renders as a hard rectangle past the rounded
       corners" rasterization bug as backdrop-filter + box-shadow on one element (see the
       comment above) — confirmed directly: identical computed box-shadow/border-radius
       on both copies, only the clip-path-carrying overlay shows the rectangle. Dropping
       its own shadow avoids the bug entirely and costs nothing visually, since the base
       copy's shadow is already sitting there underneath. */
    .nav-overlay::before {
        box-shadow: none;
    }

    .side-nav:hover {
        width: auto;
        background: rgba(255, 255, 255, 0.15) !important;
    }

    /* Frosted glass base tint follows the same light/dark split the clip-path overlay
       already uses for text/icon color — #056AE0 (the site's primary blue) tint over blue
       sections, white tint over white ones — so the pill reads consistently instead of
       nearly disappearing against a background close to its own tint color. */
    .side-nav.light {
        background: rgba(5, 106, 224, 0.5) !important;
    }

    .side-nav.dark {
        background: rgba(255, 255, 255, 0.5) !important;
    }

    /* .nav-brand is independently `position: fixed` at this breakpoint (see the rule
       further down) — a fixed-position descendant escapes its ancestor's clip-path
       entirely, so the overlay's own clip-path can no longer keep its cloned copy hidden
       correctly. Hiding it outright here avoids a permanently-visible, permanently-`.light`
       duplicate sitting on top of the real one. The real .nav-brand gets its own
       independent light/dark toggle in script.js instead (see updateClip()). Desktop is
       unaffected — the clone stays intact there, still doing its normal job. */
    .nav-overlay .nav-brand {
        display: none;
    }

    /* Both nav pills sit this far from their edge — 24px on tablet, 12px on mobile (see the
       ≤768px override below). Read by script.js too, since .nav-brand's own top/left have
       to be computed in JS (see the comment on that rule for why) rather than just written
       here as plain values. */
    :root {
        --nav-edge-offset: 24px;
    }

    /* --- Logo pill (separate from the hamburger/menu pill above) ---
       Always visible, always centered, regardless of whether the menu is open or closed —
       unlike the old design, this no longer lives inside #sideNav's collapse/expand
       machinery at all. script.js (placeNavBrand(), called from positionNavBrand()) moves
       the real element out to a direct child of <body> at this breakpoint, specifically so
       it's clear of #sideNav's own backdrop-filter: `backdrop-filter` (like
       `filter`/`transform`/`perspective`) makes the element carrying it the containing
       block for any `position: fixed` descendant, and a `position: fixed` element nested
       inside another element's backdrop-filter hits a real WebKit bug where its own
       backdrop-filter blurs the wrong thing — confirmed directly, side by side against the
       hamburger pill, which isn't nested and blurs correctly. Moved out to <body>, its
       top/left resolve directly against the real viewport with no offset math needed, which
       is also what fixes the blur: no filtered ancestor left to sit nested inside. Left
       unset here — script.js sets both directly. */
    .nav-brand {
        position: fixed;
        margin-bottom: 0;
        /* Height set explicitly (rather than left to padding + content) so it matches the
           hamburger circle's 48px exactly regardless of the icon/text's own natural size —
           align-items: center (inherited from the base .nav-brand rule) centers the icon
           and wordmark inside it. Padding is horizontal-only as a result. */
        height: 48px;
        padding: 0 12px 0 12px;
        background: rgba(255, 255, 255, 0.15) !important;
        backdrop-filter: blur(12px);
        -webkit-backdrop-filter: blur(12px);
        border-radius: 999px;
        z-index: 1000;
        overflow: visible;
        /* Its own light/dark class is toggled directly in script.js (updateClip), based on
           its own screen position — it sits at a different X than the hamburger pill (this
           one's centered, that one's pinned left), so it can't just mirror `nav-over-blue`;
           the boundary the pill crosses could be at a different Y at that X. Needs to be an
           instant swap, not eased, to stay a hard pixel-accurate split like the rest of the
           nav's color-switching. */
        transition: none;
    }

    /* Same shadow-isolation technique as the collapsed pill's `.side-nav::before` — see the
       comment there for why the shadow lives on its own filter-free layer instead of as
       this element's own box-shadow. */
    .nav-brand::before {
        content: '';
        position: absolute;
        inset: 0;
        border-radius: inherit;
        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
        z-index: -1;
        pointer-events: none;
    }

    /* No .light/.dark background override here, unlike .side-nav's — .nav-brand has no
       overlay clone to carry a second, section-matched glass layer, so it has only one
       shot at reading as frosted glass rather than nearly vanishing. The base rule's white
       tint above (shared with #sideNav's own permanently-`.dark` background) already
       covers both: it's the correct high-contrast choice over blue sections, and still
       reads fine over white ones (the ::before shadow above does the work of separating
       pill from page there). Only text/icon color still needs to flip per section, which
       .brand-overlay below now handles. */

    #navBrand .brand-icon {
        width: 30px;
        height: 30px;
    }

    /* #navBrand stays permanently .dark at this breakpoint now — same permanent pinning as
       #sideNav itself — with .brand-overlay below doing the section-matched color switching
       instead of toggling this class. ID-scoped to safely out-specificity the ancestor-based
       `.side-nav.dark` version of these same rules, since #sideNav is pinned `.dark` and is
       no longer this element's source of truth for color. */
    #navBrand.dark {
        color: #056AE0;
    }

    #navBrand.dark .brand-text {
        color: #2D2D2D;
    }

    #navBrand.dark .brand-icon .icon-blue {
        display: block;
    }

    #navBrand.dark .brand-icon .icon-white {
        display: none;
    }

    /* .brand-overlay is the same clip-path technique as the .nav-overlay clone above,
       scoped down to just .nav-brand's own icon+text (see script.js) instead of the whole
       nav — a permanently light (white) clone of that content, absolutely positioned to
       exactly cover the real content and clipped every scroll frame with the same
       inset-from-boundary math, so the color switch is a continuous pixel-accurate wipe
       through the blue/white boundary instead of an instant flip. #navBrand's own now-
       permanent .dark styling above is what shows through wherever this clip hides it.
       Nested inside the real #navBrand (rather than a standalone clone positioned
       separately) so it automatically travels along whenever placeNavBrand() moves the real
       element between #sideNav and <body> — no separate position-tracking needed. Hidden by
       default (see the base .brand-overlay rule) — this is the only breakpoint that needs
       it, since desktop already gets this same smooth switching for free from the whole-nav
       .nav-overlay clone, which still contains its own copy of .nav-brand there. */
    .brand-overlay {
        display: flex;
        position: absolute;
        inset: 0;
        align-items: center;
        gap: 12px;
        padding: 0 12px 0 12px;
        pointer-events: none;
    }

    /* Placed after the #navBrand.dark rules above so it wins the specificity tie against
       them (both ID + 3 classes; later source wins) — otherwise the .dark rules' own
       styling would win out over this permanently-light clone. */
    #navBrand .brand-overlay .brand-text {
        color: white;
    }

    #navBrand .brand-overlay .brand-icon .icon-white {
        display: block;
    }

    #navBrand .brand-overlay .brand-icon .icon-blue {
        display: none;
    }

    /* Wordmark stays visible at all times now — no more reveal-on-hover/expand. */
    .brand-text {
        opacity: 1;
        width: auto;
    }

    /* The resize guard (html.is-resizing, near the top of this file) still forces
       .brand-text to opacity:0/max-width:0 !important underneath this — needed on desktop
       to hide the label during a native window-edge drag, but on iOS Safari plain page
       scrolling fires 'resize' events too (the dynamic toolbar hiding/showing), which
       triggered that same guard and made the always-visible wordmark here flicker/collapse
       mid-scroll. Same selector + !important, placed after, so it wins the tie and keeps
       the wordmark visible at this breakpoint regardless of the guard. */
    html.is-resizing .brand-text {
        opacity: 1 !important;
        max-width: 150px !important;
    }

    /* Show hamburger — override base display:none. Sized to exactly the icon itself
       (24x24) — .side-nav's own 12px padding is what creates the circle around it now,
       rather than this button having its own separate padding on top of that. */
    .nav-hamburger {
        display: flex;
        align-items: center;
        justify-content: center;
        background: none;
        border: none;
        cursor: pointer;
        color: inherit;
        padding: 0;
        width: 24px;
        height: 24px;
        border-radius: 6px;
        transition: background 0.2s ease;
    }

    .nav-hamburger:hover {
        background: rgba(255, 255, 255, 0.1);
    }

    .side-nav.dark .nav-hamburger:hover {
        background: rgba(30, 111, 217, 0.1);
    }

    /* Close button stays hidden until menu opens */
    .nav-close {
        display: none;
    }

    /* Divider stays hidden until menu opens */
    .nav-divider {
        display: none;
    }

    /* Hide nav links in collapsed state */
    .nav-links {
        display: none;
    }

    /* Prevent desktop hover-expand text reveal */
    .side-nav:hover .nav-text {
        opacity: 0;
        width: 0;
    }

    /* --- Expanded state (is-open toggled by JS) --- */

    /* JS only ever toggles `.is-open` on the real base nav (#sideNav), never on the overlay
       clone — so once the base expands into its full-screen panel, the overlay would
       otherwise keep sitting on top of it as a small leftover icon in the corner, still
       showing whatever collapsed-state clip it last had. Hide it for the duration. */
    #sideNav.is-open ~ .nav-overlay {
        display: none;
    }

    /* .nav-brand is moved to a direct child of <body> at this breakpoint (see
       placeNavBrand() in script.js — done to get it clear of #sideNav's own
       backdrop-filter, which otherwise breaks its blur in Safari), so it's no longer a DOM
       descendant of #sideNav and a `.side-nav.is-open .nav-brand` selector can no longer
       reach it. `:has()` reads #sideNav's state from their shared parent instead, which
       works regardless of where .nav-brand actually sits. It's still its own
       independently-positioned, always-on-screen pill though — on a narrow viewport the
       expanded card's top-right corner can land right where the logo pill sits, and it
       needs to tuck in BEHIND the card's own frosted background there (obscured by its
       blur, the same way it'd be tucked behind any other opaque layer in front of it)
       rather than floating on top of it. Dropping all the way to 0 (or negative) sinks it
       below ordinary page content too — several sections (.hero-content, the about
       .section-container) set their own small z-index (1–3) to layer over decorative
       background SVGs, and any of those then painted over the brand pill
       whenever the menu was open above them. 999 stays below #sideNav.is-open's 1000 (so
       the expanded card still wins) while comfortably clearing every z-index used
       elsewhere on the page, so the brand pill only ever loses to the expanded menu. */
    body:has(#sideNav.is-open) .nav-brand {
        z-index: 999;
    }


    .side-nav.is-open {
        position: fixed;
        /* The panel opens as its own floating card above the collapsed pill rather than
           morphing in place, so its position no longer needs to match the hamburger's exact
           spot — set independently here instead of inheriting var(--nav-edge-offset). */
        top: calc(8px + env(safe-area-inset-top, 0px));
        left: calc(8px + env(safe-area-inset-left, 0px));
        /* Hug content instead of a fixed card width — the widest link label determines the
           width, so there's no dead space on the right. Capped so it can't overflow a
           narrow viewport. */
        width: fit-content;
        max-width: calc(100vw - 16px);
        height: auto;
        /* Left padding lines up the close icon's box with the nav-link icon column below it
           (see .side-nav.is-open .nav-link / .nav-icon) so the X sits directly above them.
           Right/bottom stay roomier for the links. */
        padding: 20px 24px 24px 20px;
        border-radius: 28px;
        flex-direction: column;
        align-items: flex-start;
        gap: 0;
        z-index: 1000;
        /* Same frosted-glass treatment as the collapsed pill: base tint below follows the
           light/dark section split; falls back to white here in case neither class is
           present for some reason. */
        background: rgba(255, 255, 255, 0.5) !important;
        backdrop-filter: blur(12px);
        -webkit-backdrop-filter: blur(12px);
    }

    /* No is-open-specific override here on purpose — the shared `.side-nav::before` rule
       (see the collapsed pill above) already covers this state too, and its
       `border-radius: inherit` picks up this element's 28px card radius automatically. Kept
       at the exact same shadow values as the brand pill's `.nav-brand::before` rather than a
       bumped-up size for the bigger card, per request. */

    /* Same #056AE0-based split as the collapsed pill. */
    .side-nav.is-open.light {
        background: rgba(5, 106, 224, 0.5) !important;
    }

    .side-nav.is-open.dark {
        background: rgba(255, 255, 255, 0.5) !important;
    }

    /* Swap hamburger for close button */
    .side-nav.is-open .nav-hamburger {
        display: none;
    }

    .side-nav.is-open .nav-close {
        display: flex;
        align-items: center;
        justify-content: center;
        background: none;
        border: none;
        cursor: pointer;
        color: inherit;
        padding: 0;
        width: 24px;
        height: 24px;
        border-radius: 6px;
        transition: background 0.2s ease;
        /* No divider under the close button anymore — this margin alone gives the links
           breathing room below it. */
        margin-bottom: 24px;
    }

    .side-nav.is-open .nav-close:hover {
        background: rgba(255, 255, 255, 0.1);
    }

    .side-nav.is-open.dark .nav-close:hover {
        background: rgba(30, 111, 217, 0.1);
    }

    /* Nav links: always visible with icon + text */
    .side-nav.is-open .nav-links {
        display: flex;
        flex-direction: column;
        gap: 8px;
        width: 100%;
    }

    .side-nav.is-open .nav-link {
        display: flex;
        flex-direction: row;
        align-items: center;
        gap: 12px;
        width: auto;
        font-size: 20px;
        font-weight: 400;
        /* No left padding: keeps the icon's left edge flush with the panel's own left
           padding, matching the close button's box above it (see .side-nav.is-open) so the
           icon column lines up under the X. */
        padding: 8px 8px 8px 0;
        border-radius: 8px;
        color: inherit;
    }

    /* Scaled down from the base 24px icon (desktop/collapsed use) to fit the panel's
       tighter proportions; padding zeroed so the icon's own box — not an inner gutter —
       defines its left edge, needed for the X-above-icons alignment described above. */
    .side-nav.is-open .nav-icon {
        width: 20px;
        height: 20px;
        padding: 0;
    }

    /* .nav-icon svg is sized in explicit px (see the base rule), not a percentage, so it
       has to be re-declared here to match .nav-icon's own 20px at this breakpoint. */
    .side-nav.is-open .nav-icon svg {
        width: 20px;
        height: 20px;
    }

    /* Same oversize-and-recenter treatment as the desktop .nav-icon--smiley/--tilt/--rocket
       rules, scaled down proportionally for this 20px icon box. */
    .side-nav.is-open .nav-icon--smiley svg {
        top: -2px;
        left: -2px;
        width: 24px;
        height: 24px;
    }

    .side-nav.is-open .nav-icon--tilt svg {
        top: -3px;
        left: -3px;
        width: 26px;
        height: 26px;
    }

    .side-nav.is-open .nav-icon--rocket svg {
        top: -3.5px;
        left: -3.5px;
        width: 27px;
        height: 27px;
    }

    .side-nav.is-open .nav-text {
        opacity: 1;
        /* Overrides the desktop hover-expand's resting -8px slide offset and 0
           max-width (see the unscoped .nav-text rule) — this panel's labels are
           always visible, not entering via the hover stagger, so they shouldn't sit
           pre-shifted or clipped. */
        transform: translateX(0);
        max-width: 200px;
        /* Also overrides the `.side-nav:hover .nav-text { width: 0 }` suppression rule
           just above (same specificity, but declared earlier — this wins on source
           order for the properties it shares, but that rule's `width: 0` was never
           addressed here). Real :hover and .is-open aren't mutually exclusive: opening
           the panel with a mouse/trackpad (not a pure touch tap) leaves the pointer
           resting over #sideNav, and "sticky" hover after a tap is common on touch
           devices too — whenever both are true at once, that leftover width:0 was
           winning outright (nothing here overrode it) and collapsing every label to
           zero width, confirmed directly via getComputedStyle. */
        width: auto;
        color: inherit;
    }

    /* Hover states in overlay */
    .side-nav.is-open.light .nav-link:hover {
        background: #0B60E0;
    }

    .side-nav.is-open.dark .nav-link:hover {
        background: rgba(30, 111, 217, 0.08);
    }

    /* Active state: underline only, no fill — same treatment as the desktop expanded
       nav (see the shared .nav-link.active / .nav-underline rules up top). */

    /* Text color overrides in white overlay */
    .side-nav.is-open.dark .nav-text {
        color: #2D2D2D;
    }

}

/* ============================================================
   MOBILE ONLY (≤768px)
   ============================================================ */

@media (max-width: 768px) {
    /* Both nav pills sit 12px from the edge on mobile, vs. 24px on tablet. .side-nav reads
       this directly (var(--nav-edge-offset), set on .side-nav itself); .nav-brand's
       position is computed in JS, which also reads this same variable — see the comment on
       the .nav-brand rule in the ≤1024px block above for why. */
    :root {
        --nav-edge-offset: 12px;
    }

    /* Wordmark reads large at 32px (inherited from the desktop .nav-brand rule) against
       a 30px icon on a small phone screen — sized down specifically for mobile, not
       tablet, where the larger size still reads fine next to the pill's other proportions. */
    .nav-brand {
        font-size: 24px;
    }

    /* Corner radius scaled to 50% of its desktop value, matching images shrinking down
       to the mobile breakpoint. */
    .about-image {
        border-radius: 10px; /* 20px desktop */
    }

    .project-image {
        border-radius: 6px; /* 12px desktop */
    }
}

/* ============================================================
   Photo Lightbox
   ============================================================
   Opened by clicking/activating any .play-photo (see script.js, which delegates a
   single click/keydown listener on #playTrack rather than binding one per image).
   The "picked up off the page" effect is a FLIP animation done entirely in JS
   (measuring the clicked thumbnail's live rect vs. the lightbox image's own final
   centered rect, then animating a transform between them) — CSS here only owns the
   resting states, not the motion itself, since the transform values are different on
   every open/close depending on which photo and where it currently sits. */

/* Same lift-and-dim hover treatment as .project-image:hover in the Work section, for
   the same "this is clickable" affordance. */
.play-photo {
    cursor: pointer;
    transition: transform 0.3s ease, opacity 0.3s ease;
}

.play-photo:hover {
    transform: translateY(-4px);
    opacity: 0.9;
}

/* Pauses the same auto-drift animation hover already pauses (see .play-carousel:hover
   .play-track), and locks background scroll — both while a photo is open, regardless
   of where the pointer happens to be. */
body.lightbox-open .play-track {
    animation-play-state: paused;
}

body.lightbox-open .scroll-wrapper {
    overflow: hidden;
}

.lightbox {
    position: fixed;
    inset: 0;
    z-index: 2000; /* above #sideNav's 1000 — see .side-nav / .side-nav.is-open */
    display: flex;
    align-items: center;
    justify-content: center;
    visibility: hidden;
    pointer-events: none;
}

.lightbox.is-open {
    visibility: visible;
    pointer-events: auto;
}

/* Close/prev/next controls have no fade of their own — they'd otherwise stay fully
   visible for the whole CLOSE_DURATION_MS in script.js while the photo animates back
   to its thumbnail, since the parent .lightbox only loses .is-open (and its
   visibility/pointer-events) once that animation finishes. .is-closing hides them the
   instant close() starts instead. */
.lightbox.is-closing .lightbox-close,
.lightbox.is-closing .lightbox-nav {
    visibility: hidden;
    pointer-events: none;
}

.lightbox-backdrop {
    position: absolute;
    inset: 0;
    background: rgba(10, 10, 14, 0.92);
    opacity: 0;
    transition: opacity 0.3s ease;
}

.lightbox-backdrop.is-open {
    opacity: 1;
}

/* Opacity is a hard cut, not a fade, in the default (motion-enabled) case — see the
   @media override below for the one place it actually transitions. script.js flips
   .is-visible on in the same synchronous step it applies the instant, untransitioned
   "start exactly over the thumbnail" transform, so the image only ever becomes visible
   already sitting in the right place — nothing to visibly fade. */
.lightbox-image {
    position: relative;
    max-width: 90vw;
    max-height: 85vh;
    width: auto;
    height: auto;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
    opacity: 0;
}

.lightbox-image.is-visible {
    opacity: 1;
}

/* Same frosted-glass pill treatment as the mobile nav's collapsed hamburger / expanded
   close button (see .side-nav / .side-nav.is-open .nav-close) — translucent tint +
   blur + a shadow-only pseudo-element (kept off this element's own box-shadow for the
   same Safari backdrop-filter+box-shadow rounding bug noted on .side-nav::before).
   Shared by the close button and the prev/next paging arrows below. */
.lightbox-close,
.lightbox-nav {
    position: absolute;
    width: 48px;
    height: 48px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.15);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border: none;
    border-radius: 999px;
    cursor: pointer;
    padding: 0;
    transition: background 0.2s ease;
}

.lightbox-close::before,
.lightbox-nav::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
    z-index: -1;
    pointer-events: none;
}

.lightbox-close:hover,
.lightbox-nav:hover {
    background: rgba(255, 255, 255, 0.25);
}

.lightbox-close {
    top: 24px;
    right: 24px;
}

.lightbox-nav {
    top: 50%;
    transform: translateY(-50%);
}

.lightbox-prev {
    left: 24px;
}

.lightbox-next {
    right: 24px;
}

@media (max-width: 768px) {
    .lightbox-close,
    .lightbox-nav {
        width: 40px;
        height: 40px;
    }

    .lightbox-close svg,
    .lightbox-nav svg {
        width: 20px;
        height: 20px;
    }

    .lightbox-close {
        top: 16px;
        right: 16px;
    }

    .lightbox-prev {
        left: 12px;
    }

    .lightbox-next {
        right: 12px;
    }
}

/* The one place opacity actually animates — script.js skips the transform-based FLIP
   entirely here and just relies on this fade instead. */
@media (prefers-reduced-motion: reduce) {
    .lightbox-image {
        transition: opacity 0.25s ease;
    }
}