/* ==========================================================================
   Saim Alshafi — Portfolio
   style.css — reset, tokens, fluid grid, typography, header, footer
   ========================================================================== */

/* Satoshi variable font — the site-wide typeface, per direct request
   (was homepage-only preview, then replaced the six PP Neue Montreal
   static-weight @font-face rules that used to sit here — those were
   removed as part of the pre-launch cleanup). Single variable file
   covers the whole 300–900 weight axis instead of separate static
   files per weight. */
@font-face {
  font-family: "Satoshi";
  src: url("../fonts/Satoshi-Variable.woff2") format("woff2"),
       url("../fonts/Satoshi-Variable.woff") format("woff");
  font-weight: 300 900;
  font-style: normal;
  font-display: swap;
}

/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body, h1, h2, h3, h4, p, figure, blockquote { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: none; cursor: pointer; }
img { display: block; max-width: 100%; }

/* ---------- fluid scale ----------
   Design baseline: MacBook Pro 1728 x 1117 (same mechanism vucko.co
   uses: root font-size tracks viewport width 1:1, every component is
   sized in rem, so the whole layout scales as one unit and every
   element's position stays a constant percentage of the page — nothing
   reflows independently).
   Root font-size scales 1:1 with viewport width down to the point
   where 100vw/1728*16 == 9.5px (~1026px viewport) — that's our
   mobile-breakpoint floor until the dedicated mobile layout is built.
   Everything below is authored in rem against a 16px design pixel.
   NOTE: the multiplier must be unitless (`* 16`, not `* 16px`) — calc()
   can't multiply two length values together; doing that makes the
   whole clamp() invalid and silently falls back to the browser default
   16px, which is a static, non-scaling number. */
html {
  font-size: clamp(9.5px, calc(100vw / 1728 * 16), 16px);
  scroll-behavior: auto; /* Lenis handles smoothing */
  /* Fix for mobile: body already had overflow-x:hidden, but mobile
     browsers govern horizontal rubber-band/bounce overscroll at the
     <html>/document level, not body's — body-only containment still let
     a left/right swipe drag the whole viewport past its edge, briefly
     revealing whatever sits behind the normal page content underneath
     (the fixed-position footer — see .site-footer below). Both
     properties matter: overflow-x actually stops any real horizontal
     scroll capability; overscroll-behavior-x additionally suppresses
     the gesture-triggered bounce effect itself (Chrome/Android; Safari
     doesn't support this property but respects the overflow-x fix). */
  overflow-x: hidden;
  overscroll-behavior-x: none;
}

/* ---------- mobile breakpoint ----------
   768px, a plain max-width media query — being built page by page (see
   comments on individual mobile rules throughout this file and each
   page's own CSS for which pages currently have real mobile layouts
   vs. still just inheriting the floored desktop grid).
   Freezes the fluid root font-size at its own 16px ceiling instead of
   letting it keep sliding down to the 9.5px floor documented above:
   at exactly 16px, every existing rem-based measurement site-wide
   (paddings, gaps, the whole type scale) renders at its literal
   "design pixel" value — the same number the comment next to each rule
   already documents in px — so mobile-specific overrides below can be
   authored in rem against that same 16px baseline and land exactly on
   the pixel values quoted for them, no separate mental conversion. */
@media (max-width: 768px) {
  html {
    font-size: 16px;
  }
}

:root {
  --black: #0B0A0B; /* was pure #000000 — per direct request */
  --white: #ffffff; /* pure white — per direct request */
  --gray: #bababa; /* secondary text — was #a0a0a0, consolidated with the other near-identical gray (#ababab) that used to live separately in the footer */

  --margin: 2.5rem;   /* 40px  */
  --gutter: 1.5rem;   /* 24px  */

  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
}

body {
  background: var(--white);
  color: var(--black);
  font-family: "Satoshi", Arial, sans-serif; /* site-wide, per direct request — was "PP Neue Montreal" */
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  overscroll-behavior-x: none;
  transition: background-color 0.4s var(--ease-out), color 0.4s var(--ease-out);
}

.c-gray { color: var(--gray); }

/* ---------- text selection (site-wide) ----------
   Custom highlight color, per direct request — otherwise every browser
   falls back to its own default blue selection, which clashes with the
   site's own palette. ::-moz-selection kept alongside the standard
   ::selection since Firefox historically required the prefixed rule
   (both are cheap to keep in sync). */
::selection {
  color: #0B0A0B;
  background: #FFA16E;
}
::-moz-selection {
  color: #0B0A0B;
  background: #FFA16E;
}

/* ---------- dark mode ----------
   Scoped narrowly on purpose: only body background/text and the one
   hardcoded-black element (the struck-through cities) flip. Anything
   already set in --gray stays --gray in both modes (explicit user
   requirement), and the footer is untouched entirely — it already
   hardcodes black/white regardless of theme, so it's naturally excluded
   just by never being targeted here. */
html.dark-mode body {
  background: #0B0A0B;
  color: #ffffff; /* pure white — per direct request */
}

html.dark-mode .hero__based s {
  color: #ffffff; /* pure white — per direct request */
}

/* ---------- grid ---------- */
.grid-12 {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: var(--gutter);
  padding-inline: var(--margin);
}

/* ---------- text reveal (shared) ----------
   Lines are split by JS into .line-mask (overflow hidden) > .line-inner
   (the element that actually translates). Only transform is ever
   animated — no opacity, no blur. */
.line-mask {
  display: block;
  overflow: hidden;
}
.line-inner {
  display: block;
  will-change: transform;
  transform: translateY(110%);
}
[data-reveal].is-inline {
  display: inline-block;
}

/* ---------- scroll highlight (shared) ----------
   Grey by default, flips to full contrast while the element is passing
   through a fixed band in the viewport — toggled by
   Common.initScrollHighlight() (see common.js) adding/removing
   .is-active per element via ScrollTrigger's toggleClass, not a scrubbed
   tween. This is intentionally a CSS transition + class toggle rather
   than animating color directly with GSAP: color isn't a great scrub
   target (no easy way to interpolate to "whatever the current theme's
   full-contrast color is" without duplicating theme logic in JS), and a
   binary on/off state driven by a normal CSS transition already handles
   dark mode for free via the existing .is-active dark-mode override
   below — same pattern already used for .pg-card__title. First used for
   the About page's Experience/Education rows and Expertise words. */
.scroll-highlight {
  color: var(--gray);
  transition: color 0.4s var(--ease-out);
}
.scroll-highlight.is-active {
  color: var(--black);
}
/* --gray (#bababa) is tuned for the light background — on black it's too
   close to full contrast to read as "not yet highlighted", so dark mode
   gets its own, much darker pre-highlight grey instead of reusing --gray. */
html.dark-mode .scroll-highlight {
  color: #1e1e1e; /* was #1a1a1a — consolidated onto the site's other near-black gray */
}
html.dark-mode .scroll-highlight.is-active {
  color: #ffffff; /* pure white — per direct request */
}

/* ---------- page transitions ----------
   A single black panel (see js/transitions.js) that slides up from the
   bottom of the viewport to fully cover the screen — a plain curtain,
   opacity/transform/visibility only so GSAP owns the animation cleanly.
   The actual content swap (#page-root's innerHTML, plus the header's
   .logo when the transition enters or leaves the homepage — see that
   file's top comment) happens hidden behind it once the rise finishes,
   then the panel is dropped instantly and the destination page's own
   normal reveal-in timeline populates it — same "arrives empty, then
   populates" feel as a hard reload, just without an actual navigation.
   z-index is set dynamically by transitions.js on every navigation, not
   fixed here — 100 (below .site-header--logo's 300) for an ordinary
   non-home-involved transition, so the small wordmark logo stays put
   and visible the whole time since it never actually changes; 400
   (between .site-header--logo's 300 and .site-header--nav's 500) only
   when the transition enters or leaves the homepage, since that's the
   one case where .logo itself gets swapped and needs to be covered
   while that happens. .site-header--nav is 500 unconditionally either
   way — nav is never covered by any transition, full stop. This rule's
   own z-index (100) is just the resting/pre-JS value; see PANEL_Z_BELOW_LOGO
   / PANEL_Z_ABOVE_LOGO in transitions.js for the real per-transition logic. */
.page-transition-panel {
  position: fixed;
  inset: 0;
  z-index: 100;
  background: #0B0A0B;
  visibility: hidden;
  pointer-events: none;
}

/* ---------- header ----------
   Two independent position:fixed elements sharing this base class,
   rather than one <header> wrapping both .logo and .nav. A single fixed
   + z-indexed element is its own stacking context — everything inside
   it (nav included) can only be painted as one unit relative to
   anything outside it (like .page-transition-panel above), so there was
   no z-index that could put the panel "over the logo, under the nav"
   while they were both children of the same element. Splitting them
   into two real siblings gives each its own independent position in the
   stacking order — see the z-index comment on each variant below. */
.site-header {
  position: fixed;
  top: 0;
  padding: var(--margin);
  color: var(--white);
  mix-blend-mode: difference;
  pointer-events: none;
}
.site-header * { pointer-events: auto; }

.site-header a,
.logo {
  color: var(--white);
}

.site-header--logo {
  left: 0;
  z-index: 300; /* Sits ABOVE the panel's resting z-index (100) — an
    ordinary transition between two non-home pages never covers the
    logo, since it doesn't change and there's nothing to hide. Only
    when transitions.js bumps the panel to 400 (entering/leaving the
    homepage, where .logo itself gets swapped) does the panel end up
    above this and cover it, exactly like ordinary page content, for
    that one case. */
}

.site-header--nav {
  right: 0;
  z-index: 500; /* ABOVE the panel at either of its z-indexes (100 or
    400) — nav/theme-toggle are never covered or interrupted by any
    transition, home-involved or not. */
  /* Reproduces the vertical centering the old single flex row gave for
     free (align-items:center centering .nav, the shorter of the two,
     against .logo's height, the taller). .logo-svg is a fixed 1.5rem
     tall; .nav's own height is set by .nav a's line-height:1 at its
     font-size — recalculated to (1.5rem - 0.875rem) / 2 = 0.3125rem when
     .nav a dropped from 1.125rem to 0.875rem (14px, uppercase), added on
     top of the shared var(--margin) top inset so .nav still centers on
     the same baseline instead of drifting off .logo's midline now that
     it's a shorter line of text. */
  padding-top: calc(var(--margin) + 0.3125rem);
}

.logo {
  font-size: 1.125rem; /* 18pt */
  font-weight: 500; /* medium — was 600 semibold. Inert now that every
    page's .logo holds an SVG mark instead of text (see the note below),
    kept in sync anyway in case a text logo is ever reintroduced. */
  letter-spacing: -0.01em;
}

/* SVG logotype mark, used site-wide as of the homepage redesign — every
   page's .logo now holds this instead of the plain "Saim Alshafi" text.
   Docked at a static 24px
   height everywhere; only the default homepage (index.html) additionally
   wraps its copy of this same SVG in .logo-scale/.logo-glyph for the
   big scroll-driven hero treatment (see css/home.css) — every other
   page just shows it at rest, revealed once via the existing shared
   Common.initHeaderIntro() (see common.js), same as the nav links,
   since a plain SVG with no text content is treated as one atomic
   line by TextReveal.splitLines() and needs no special handling here. */
.logo svg {
  display: block;
  height: 1.5rem; /* 24px, matches the homepage's docked/rest size exactly */
  width: auto;
}

.nav {
  display: flex;
  align-items: center;
  gap: 1.75rem;
  position: relative; /* anchors .nav-dot below — flex layout of the
    links themselves is untouched by this, position only affects how
    .nav itself would be positioned relative to ITS OWN containing
    block, never how it lays out its children */
}

.nav a {
  font-size: 0.875rem; /* 14px, uppercase — was 1.125rem/18px sentence case */
  font-weight: 500; /* medium — was 400 regular */
  text-transform: uppercase;
  letter-spacing: 0.03em;
  position: relative;
  line-height: 1;
  color: var(--white);
  transition: color 0.35s var(--ease-in-out);
  /* Padding + equal negative margin widens the actual hoverable/
     clickable box by 8px on every side without moving anything
     visually or in layout — the negative margin pulls the expanded
     box back to the exact same footprint the flex item had before,
     so link spacing/centers (and the nav-dot's centering math, which
     reads offsetLeft/offsetWidth off this box) are all untouched. Per
     direct request: the old hit area was flush with the letters. */
  padding: 0.5rem;
  margin: -0.5rem;
}

/* The vucko.co-style "spotlight" dim-on-hover (every link but the
   hovered one fading to gray) was removed per direct request — the
   sliding dot below is now the only hover feedback: hovering ANY link
   (not just the active one) pulls the dot up to sit above it, in a
   fresh random color, then sends it back to the active page the moment
   the cursor leaves the nav entirely. See bindNavDotHover in common.js. */

/* ---------- active-page indicator: sliding dot ----------
   Replaces the earlier sliding-underline treatment (a per-link ::after
   that faded in/out under whichever link was current) with a single
   6x6px circle that physically travels between links — to the new
   active link on every page change, and now also to whichever link is
   hovered (see the comment above) — instead of a per-link fade. Has to
   be ONE shared element rather than a pseudo-element per link — a
   ::after can only ever animate within its own link's box, it can't
   slide across to a sibling's — so this lives once in the markup (see
   .nav-dot in every page's <nav>) and gets positioned/animated/colored
   entirely from JS (see Common.initNavUnderline + bindNavDotHover in
   common.js, which still own this despite the "underline" name — kept
   to avoid touching every page's own init(navDelay) call site).

   position:absolute takes it completely out of the flex flow .nav a's
   own spacing/gap depends on, so adding this element has zero effect
   on where any link sits — confirmed live, per direct request ("make
   sure the original places of the nav links doesn't shift").

   left:0 is just the base value JS's gsap.set/gsap.to transforms move
   away from (translateX via GSAP's own x property, not by animating
   left directly) — transform-based movement avoids triggering layout
   on every frame the same way the site's other moving UI (cursor
   labels, etc.) already does. top:-0.5rem sits it ABOVE the links now
   (was below) — 8px total, i.e. the dot's own 6px height plus a 2px
   gap to the link's top edge, per direct spec. background is just the
   pre-JS fallback — every real appearance sets a fresh color pulled
   from the exact same 6-color palette (and no-repeat picker) as the
   site's cursor-follow hover labels, per direct request; see
   pickCursorLabelColor in common.js. */
.nav-dot {
  position: absolute;
  left: 0;
  top: -0.625rem; /* 10px = 6px dot height + 4px gap, per direct spec */
  width: 0.375rem; /* 6px */
  height: 0.375rem;
  border-radius: 50%;
  background: #ff6831;
  pointer-events: none;
  opacity: 0;
  will-change: transform;
}

/* ---------- mobile nav: hamburger + full-page menu ----------
   Shared markup/CSS/JS (see Common.initMobileMenu in common.js) even
   though only index.html carries the actual <button>/.mobile-menu
   markup for now — the mobile breakpoint is being built one page at a
   time, and every page shares the same .site-header, so wiring this
   here once means later pages just need the same HTML block dropped
   in, no new CSS/JS. .nav-toggle is desktop-hidden by default; only
   the mobile breakpoint below shows it (and hides the plain link row).
   Not using mix-blend-mode:difference here like the rest of .site-header
   — a full-bleed panel already guarantees its own contrast, so the
   trick that exists specifically for text sitting over arbitrary page
   content underneath isn't needed once that panel is open. */
.nav-toggle {
  display: none;
  position: relative; /* anchors the enlarged invisible tap-target pseudo-element below */
  flex-direction: column;
  justify-content: center;
  align-items: flex-end;
  gap: 0.375rem; /* 6px between the two bars */
  width: 1.5rem;
  height: 1.5rem; /* matches .logo-svg's own 24px resting height, so it lines up on the same row */
  /* It's a <button>, which carries UA-default padding/border (Chrome:
     ~1px 6px) — left in, that ate into the 24px box on the cross axis
     and left only ~12px for the bars to size themselves against (their
     own width:100% resolves against this padded-down content box), so
     the hamburger rendered as two stubby half-width dashes AND the X
     morph below (tuned for a full 24px-wide bar) converged on the wrong
     point, reading as a crossed/lopsided glitch rather than a clean X.
     Resetting to a plain, unstyled box fixes both at once. */
  padding: 0;
  border: 0;
  background: none;
  appearance: none;
  cursor: pointer;
  color: inherit;
}

/* Reported bug: "the menu button is very hard to click" — the visual
   icon is a deliberately small 24px box (matches the logo's own resting
   height), well under the ~44px minimum tap target both Apple's and
   Google's mobile guidelines call for. Rather than grow the icon itself
   (which would throw off its alignment with the logo row), this expands
   only the invisible HIT area via an absolutely-positioned pseudo-
   element with negative inset — same "keep the visible thing the same
   size, make the tappable area bigger" trick applied to every other
   small tap target fixed alongside this one. */
.nav-toggle::before {
  content: "";
  position: absolute;
  inset: -0.625rem; /* 10px each side: 24px box -> 44px effective tap target */
}

.nav-toggle__bar {
  display: block;
  width: 100%;
  height: 2px;
  background: currentColor;
  transition: transform 0.35s var(--ease-in-out), opacity 0.35s var(--ease-in-out);
}

/* Classic hamburger -> X morph: top bar rotates+drops onto the middle,
   bottom bar rotates+rises onto the middle. Both bars are already a
   full 24px wide at rest (no width change needed once the button reset
   above stopped UA padding from shrinking them) — translateY is exactly
   half of (bar height + gap) = (2px + 6px) / 2 = 4px, so each bar's
   center lands precisely on the icon's own center and the two overlap
   into a true, symmetric X instead of crossing off-center. */
.nav-toggle.is-open .nav-toggle__bar:first-child {
  transform: translateY(0.25rem) rotate(45deg);
}
.nav-toggle.is-open .nav-toggle__bar:last-child {
  transform: translateY(-0.25rem) rotate(-45deg);
}

/* Full-page overlay panel. Solid (not blended) black — sits above
   .site-header--nav's own z-index (500) so it fully covers the small
   docked logo/nav row underneath while open, and above the page
   transition panel's highest z-index (400) so a menu left open can
   never end up hidden behind an in-flight page transition. Toggled via
   .is-open only (opacity + a small upward slide), not display:none, so
   the close transition can actually play instead of snapping away. */
.mobile-menu {
  display: none; /* only ever laid out at the mobile breakpoint below */
  position: fixed;
  inset: 0;
  z-index: 600;
  background: #0B0A0B;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  padding: var(--margin);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-0.75rem);
  transition: opacity 0.4s var(--ease-in-out), transform 0.4s var(--ease-in-out), visibility 0s linear 0.4s;
  pointer-events: none;
}

.mobile-menu.is-open {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: opacity 0.4s var(--ease-in-out), transform 0.4s var(--ease-in-out), visibility 0s linear 0s;
  pointer-events: auto;
}

.mobile-menu__close {
  position: absolute;
  top: var(--margin);
  right: var(--margin);
  width: 1.5rem;
  height: 1.5rem;
}

/* Same enlarged-invisible-tap-target fix as .nav-toggle above, applied
   consistently to every small mobile control per the same direct
   request ("make sure all click points ... are generous"). */
.mobile-menu__close::before {
  content: "";
  position: absolute;
  inset: -0.625rem;
}

.mobile-menu__close-bar {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 1.5rem;
  height: 2px;
  background: var(--white);
  transform-origin: center;
}
.mobile-menu__close-bar:first-child {
  transform: translate(-50%, -50%) rotate(45deg);
}
.mobile-menu__close-bar:last-child {
  transform: translate(-50%, -50%) rotate(-45deg);
}

.mobile-menu__nav {
  display: flex;
  flex-direction: column;
  /* Without this, flex's own default align-items:stretch stretches
     every <a> (a flex item, cross-axis = width in a column flex
     container) to the full width of the menu — which is also why the
     is-active underline below (width:100% of the <a>'s own box) was
     stretching past short words like "Work"/"Doodles" to match that
     full container width instead of the word's own text width. */
  align-items: flex-start;
  gap: 2rem; /* 32px — was 24px, +8px per direct request */
}

.mobile-menu__nav a {
  position: relative;
  font-size: 2rem; /* 32px */
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--white);
}

/* Same underline-marks-current-page language as the desktop .nav a
   (see .nav a::after below) instead of a color change, per direct
   spec — text stays full white/normal weight, just gets a static
   underline. No JS/animation needed here (unlike the desktop version,
   which slides in via Common.initNavUnderline) since this list is
   static per-page HTML, not scroll/route-driven. */
.mobile-menu__nav a.is-active::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -0.375rem;
  width: 100%;
  height: 1px;
  background: currentColor;
}

/* body scroll lock while the menu is open — Lenis itself is also
   stopped (see Common.initMobileMenu), this just backstops any native
   scroll/touch-scroll Lenis doesn't intercept. */
body.mobile-menu-open {
  overflow: hidden;
}

@media (max-width: 768px) {
  .nav {
    display: none;
  }
  .nav-toggle {
    display: flex;
  }
  .mobile-menu {
    display: flex;
  }

  /* ---------- 24px side padding ----------
     Every page's left/right inset drops from var(--margin)/40px to 24px
     at this breakpoint, per direct spec. Deliberately overriding just
     the inline component on each affected selector rather than
     redefining --margin itself: that token is also reused for vertical
     spacing in several places (header padding-block, hero padding-
     bottom, footer margins, etc.) that were NOT part of this request and
     whose values other mobile rules already depend on (e.g. the hero's
     11.5rem padding-top math above assumes the header's own 40px
     top/bottom padding is unchanged) — redefining --margin wholesale
     would quietly shrink all of those too. */
  .site-header {
    padding-inline: 1.5rem;
    padding-top: 1.5rem; /* 24px, per direct spec — was var(--margin)/40px */
  }
  .site-header--nav {
    /* Overrides the desktop-only baseline-centering offset above
       (calc(var(--margin) + 0.3125rem)) — that formula centers the nav
       *text* row against the logo using the old 40px top inset; on
       mobile .nav-toggle is a fixed 24px icon box, not a text row, so it
       just needs the same flat 24px top as .site-header itself. */
    padding-top: 1.5rem;
  }
  .mobile-menu {
    padding-inline: 1.5rem;
    /* Nav links no longer center in the free space below the close
       icon — they start a flat 120px from the top instead, per direct
       spec (was 80px, before that 24px below the close icon's own
       bottom edge). */
    padding-top: 7.5rem; /* 120px */
    justify-content: flex-start;
  }
  .mobile-menu__close {
    top: 1.5rem; /* 24px, per direct spec — was var(--margin)/40px, matching the header's own new top offset */
    right: 1.5rem;
  }
  .grid-12 {
    padding-inline: 1.5rem;
  }
}

/* ---------- theme toggle (header circle) ----------
   At rest it's nothing but the 1rem dot — no pill, no visible
   background, identical footprint to the original static circle.
   Only on :hover does a translucent track fade/grow in behind it to
   reveal the vertical travel (vertical instead of vucko's horizontal).
   Both the dot and the track use currentColor (the header's inherited
   white), so the existing mix-blend-mode:difference on .site-header
   keeps doing the contrast work for free: a translucent white track
   diffed against a light backdrop reads as a soft gray pill, against a
   dark backdrop as a soft dark pill; the solid dot reads pure black on
   light, pure white on dark — no separate light/dark color logic
   needed here at all. */
.theme-toggle {
  width: 1rem;
  height: 1rem;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  flex-shrink: 0;
}

.theme-toggle__track {
  display: block; /* spans are inline by default — width/height/border-radius are no-ops without this, which is why the dot went invisible */
  position: relative;
  width: 1rem;
  height: 1rem;
  border-radius: 0.5rem;
  background: transparent;
  transition: height 0.45s var(--ease-out), background-color 0.4s var(--ease-out);
}

.theme-toggle:hover .theme-toggle__track {
  height: 2.5rem;
  background: rgba(255, 255, 255, 0.15);
}

/* Positioned with transform rather than flex justify-content: the
   latter isn't an animatable property, so toggling it made the thumb
   snap instantly instead of sliding — transform is, so this is the
   part that actually produces the smooth motion. The offset only
   applies while hovering (paired with :hover in the selector below),
   so the collapsed/idle dot is always just centered — matching the
   reference, where light-idle and dark-idle sit in the same spot and
   only differ in color. */
.theme-toggle__thumb {
  display: block;
  width: 1rem;
  height: 1rem;
  border-radius: 50%;
  background: currentColor;
  transition: transform 0.45s var(--ease-out);
}

.theme-toggle[aria-pressed="true"]:hover .theme-toggle__thumb {
  transform: translateY(1.5rem); /* 2.5rem expanded track - 1rem thumb */
}

/* ---------- footer ---------- */
.footer-spacer {
  height: var(--footer-height, 33.9375rem);
  width: 100%;
}

.site-footer {
  position: fixed;
  left: 0;
  bottom: 0;
  width: 100%;
  height: var(--footer-height, 33.9375rem);
  background: #ff6831; /* logo orange — experimental, per direct request; was var(--black) */
  color: var(--white);
  z-index: 1;
  display: flex;
  flex-direction: column;
  /* padding-bottom (not justify-content:center) is what guarantees the
     bottom row sits a fixed distance off the box's true bottom edge,
     regardless of content height (responsive type scaling, a longer/
     shorter tagline, etc.) rather than a soft equal-share that a taller
     box or shorter content would throw off. .footer__headline's own
     margin-top:auto (see below) pushes the headline — and, via normal
     flow, everything after it — down to sit just above this padding, so
     the whole block reads as bottom-anchored with a precise, fixed
     final gap.
     16px content-to-strip gap + 8px strip height (see .site-footer::after)
     = var(--gutter)/24px exactly — per direct request ("footer content
     should start 16px above the strip, not 24px"). */
  padding-bottom: var(--gutter);
  overflow: hidden;
}

/* Large faint logo watermark (own opacity baked into the SVG itself,
   0.02 — see assets/footer-logo-watermark.svg) spanning the footer's
   full width. Per direct spec: top of the logo = top of the footer, so
   plain top:0/left:0 rather than centering — width:100% + height:auto
   keeps it matching the box's full width at any viewport, following
   the SVG's own 1728:543 aspect ratio. No explicit z-index — sits below
   .site-footer__scrim (z-index:4) in the stacking order, so it dims and
   brightens with everything else during the scroll-reveal, and below
   .site-footer::after (the strips) simply by being earlier in the DOM,
   which is what makes the strips read as painted on top of its very
   bottom edge rather than the two fighting for the same pixels. */
.footer__logo {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
  display: block;
  pointer-events: none;
}

/* Two 4px logo-color strips pinned to the very bottom of the footer —
   yellow above green, per direct request/reference to the header logo's
   own glyph colors (see about.html's icon-glyph colors, same palette
   contact-shapes.js's circle/triangle already use). A single pseudo-
   element with a hard-stop gradient rather than two separate elements —
   no interaction/JS needed, so there's no reason for this to be real
   DOM. Sits below .site-footer__scrim in paint order (no z-index set,
   scrim is z-index:4), so it dims and brightens along with the rest of
   the footer during the scroll-reveal rather than staying flat/exempt —
   consistent with how everything else in the footer already behaves. */
.site-footer::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 0.5rem; /* 8px total: 4px yellow + 4px green */
  background: linear-gradient(to bottom, #ffae21 0%, #ffae21 50%, #1ce2a0 50%, #1ce2a0 100%);
  pointer-events: none;
}

/* Dim-to-bright reveal: a plain black scrim sitting over the whole
   footer, faded from an initial opacity down to 0 by
   Common.initFooterScrim() (see common.js) as the footer scrolls into
   view — scrubbed to the exact same scroll range that physically
   uncovers the footer (the .footer-spacer's own height), so the two
   stay in sync: the footer feels like it emerges dim and brightens up,
   rather than just popping in at full contrast the instant .page
   scrolls out of the way. Deliberately a separate overlay layer rather
   than animating .site-footer's own text/opacity — this site's rule is
   "no opacity on reveal-IN" for text (see the text-reveal setup in
   js/reveal.js/common.js); this keeps that rule intact for the actual
   copy while still getting a darkness-based entrance for the section
   as a whole. */
.site-footer__scrim {
  position: absolute;
  inset: 0;
  z-index: 4;
  background: #0B0A0B;
  opacity: 0.9; /* was 0.7 */
  pointer-events: none;
}

/* The footer's own CTA headline — same copy as the Contact page's hero
   title, restated here so the footer doubles as a persistent "get in
   touch" prompt on every page, not just Contact. Anchored a fixed 80px
   from the TOP of the box (not bottom-anchored via margin-top:auto
   like the first pass) — .footer__links reclaims that auto-margin job
   below instead, so the two ends of the footer now anchor
   independently: headline pinned near the top, links+bottom-row pinned
   to the bottom, whatever space is left between them is just flexible
   gap. */
.footer__headline {
  margin-top: 5rem; /* 80px, per direct spec */
  padding: 0 var(--margin);
  font-size: 5rem; /* 80px, per direct spec — matches every other page's hero-title scale */
  font-weight: 500; /* medium, matches every other headline site-wide */
  /* 1.25, not the tighter 1.1: same Satoshi-descender-clipping fix used
     on every other 80px headline site-wide (see .about-hero__title,
     .pg-hero__title, .contact-hero__title, .doodles-hero__title). */
  line-height: 1.25;
  color: #ffffff; /* per direct spec */
}

.footer__links {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 var(--margin);
  margin-top: auto; /* collects the leftover space between .footer__headline above and this row, so this + the bottom row below stay pinned to the bottom regardless of headline/content height */
  margin-bottom: var(--margin); /* 40px — the big links sit this far above the small bottom row */
}

.footer__links a {
  font-size: 2.5rem; /* 40pt */
  font-weight: 600; /* semibold, per direct spec */
  color: #0B0A0B; /* per direct spec — was var(--white) */
  letter-spacing: -0.01em;
  transition: color 0.4s var(--ease-out);
}

.footer__links a:hover {
  color: #ffffff; /* per direct spec — was an opacity fade, back when resting color was already white and dimming was the only way to show hover; now that resting is black, hover flips straight to white instead */
}

/* ---------- footer cursor-follow label ----------
   The site's standard solid-color chip hover label (see .pg-cursor-label
   in ai-playground.css, and its own top-of-block comment for the full
   design/color-randomization explanation) reused here for the footer's
   email/linkedin/github/resume links — see Common.initFooterLinkLabels
   in common.js for the JS half. Duplicated rather than shared across
   stylesheets since ai-playground.css only loads on that one page but
   this footer lives on every page (this file, style.css, is the one
   stylesheet every page always includes). Every value below is a
   deliberate 1:1 copy of .pg-cursor-label; keep the two in sync if that
   block ever changes. */
.footer-cursor-label {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 950;
  pointer-events: none;
  display: flex;
  flex-direction: row-reverse; /* DOM is text-then-icon; flips it to icon-first visually, same as .pg-cursor-label */
  align-items: center;
  gap: 0.25rem; /* 4px, per direct spec */
  color: #0B0A0B; /* black — per direct request, reads against every chip color */
  font-family: "Satoshi", sans-serif;
  font-size: 0.75rem; /* 12px, per direct spec */
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0;
  line-height: 1;
  white-space: nowrap;
  padding: 0.5rem 0.375rem 0.5rem 0.25rem; /* 8px top/bottom, 6px right, 4px left — per direct spec */
  border-radius: 0; /* sharp corners, per direct spec */
  background: #ff6831; /* JS overwrites this per zone entry — see pickCursorLabelColor in common.js; this is just the pre-JS fallback */
  opacity: 0;
  will-change: transform, opacity;
}

.footer-cursor-label__icon {
  display: flex; /* every zone gets an icon now */
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.footer-cursor-label__icon img {
  width: 0.75rem; /* 12px, matches text size */
  height: 0.75rem;
  display: block;
}

/* ---------- toast ----------
   Site-wide, device-independent confirmation (see Common.showToast in
   common.js) — currently only used for the footer email link's "copied
   to clipboard" moment, but built as a generic, reusable component
   rather than anything email-specific, so anything else that needs a
   quick confirmation later can just call the same function. Fixed to
   the bottom-center of the viewport rather than cursor-anchored like
   .footer-cursor-label/.pg-cursor-label — those only ever show on a
   real mouse hover, which is exactly why a tap-only device (no hover)
   needs its own independent confirmation here instead. */
.toast {
  position: fixed;
  left: 50%;
  bottom: 2.5rem; /* 40px, matches var(--margin) */
  z-index: 1000; /* above literally everything else on the page, including the mobile menu (600) and its own close icon */
  transform: translate(-50%, 0.75rem);
  max-width: calc(100vw - 3rem);
  padding: 0.875rem 1.25rem; /* 14px/20px */
  border-radius: 0.5rem; /* 8px */
  background: rgba(20, 20, 20, 0.92);
  backdrop-filter: blur(1.25rem); /* 20px */
  -webkit-backdrop-filter: blur(1.25rem);
  color: #ffffff;
  font-family: "Satoshi", sans-serif;
  font-size: 0.875rem; /* 14px */
  font-weight: 500;
  line-height: 1.3;
  text-align: center;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
}

.toast.is-visible {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* Small muted row: tagline (left) / back-to-top (true center) / reaction
   counters (right) — a 3-column grid rather than justify-content:
   space-between so the middle item stays visually centered regardless of
   how the two side items' widths compare. */
.footer__bottom-row {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 0 var(--margin);
  color: rgba(0, 0, 0, 0.5); /* per direct spec — was #383838 */
}

.footer__bottom-row p,
.footer__bottom-row a {
  font-size: 0.875rem; /* 14pt */
  font-weight: 400;
  line-height: 1;
  color: rgba(0, 0, 0, 0.5); /* per direct spec — was #383838 */
}

.footer__bottom-row > :first-child {
  justify-self: start;
}

.footer__bottom-row > :nth-child(2) {
  justify-self: center;
}

.footer__bottom-row > :last-child {
  justify-self: end;
}

.footer__bottom-row a {
  position: relative; /* anchors the enlarged invisible tap-target pseudo-element below */
  transition: color 0.4s var(--ease-out);
}

/* line-height:1 on these links (see .footer__bottom-row a/p above) packs
   the text tight vertically — fine for reading, cramped for a thumb.
   Same enlarged-invisible-tap-target trick as .nav-toggle/.mobile-
   menu__close, sized a bit more generously on the vertical axis (these
   sit in an otherwise-empty row with room to spare) since that's the
   tighter dimension here, not width. */
.footer__bottom-row a::before {
  content: "";
  position: absolute;
  inset: -0.75rem -0.5rem;
}

.footer__bottom-row a:hover {
  color: var(--white);
}

/* Reaction counters — two pill buttons (heart / rock-on). Per direct
   spec (orange-footer pass): black border at 30% opacity, black text,
   no blend mode — the earlier difference-blend treatment was there to
   stay legible against a black footer regardless of what sat behind
   it; now that both the footer and the border/text colors are fixed,
   flat known-good colors instead of a blend mode. Each click hits a shared counter
   API (see Common.initFooterReactions in common.js) and every visitor
   sees the same live, global total — not a per-browser count. One like
   per person per button (tracked in localStorage) — clicking again
   removes it; .is-active is that "you've liked this" state. */
.footer__reactions {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.footer__reaction {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  /* Deliberately tight/"hugged" per direct spec — 4px left/right, 2px
     top/bottom, rather than the more generous pill padding used
     elsewhere; the box is meant to hug its content and only grow (from
     its right edge staying anchored via .footer__bottom-row's own
     justify-self:end — see above) as the digit count itself grows, e.g.
     9 -> 10 -> 100. */
  padding: 2px 4px;
  border-radius: 999px;
  border: 1px solid rgba(0, 0, 0, 0.5); /* per direct spec — was #383838 */
  background: transparent;
  font-family: inherit;
  font-size: 0.875rem; /* 14px */
  font-weight: 400;
  color: #0B0A0B; /* per direct spec — was var(--gray) */
  cursor: pointer;
  transition: background-color 0.35s var(--ease-out), border-color 0.35s var(--ease-out),
    color 0.35s var(--ease-out);
  /* Isolated so the white fill (see .is-active below) composites against
     THIS element's own background only, not neighboring pills or the
     footer scrim behind them. */
  isolation: isolate;
  /* Anchors the enlarged invisible tap-target pseudo-element below —
     the pill's real box is only ~18px tall (14px text + 2px padding),
     well under the ~44px minimum recommended touch target. */
  position: relative;
}

.footer__reaction::before {
  content: "";
  position: absolute;
  inset: -0.625rem;
}

.footer__reaction:hover {
  border-color: rgba(0, 0, 0, 0.7);
}

/* "You've liked this" — persistent (not a flash) once clicked, per
   direct spec: solid white fill, black text, no blend mode — same
   flat-colors reasoning as the resting state above. Click it again to
   release (see Common.initFooterReactions) and it eases back to the
   resting state above via the same transition. */
.footer__reaction.is-active {
  background: #ffffff; /* pure white — per direct request */
  border-color: #ffffff;
  color: var(--black);
}

.footer__reaction-icon {
  font-size: 0.75rem; /* 12px per spec */
  line-height: 1;
}

.footer__reaction-count {
  font-size: 0.75rem; /* 12px per spec, matching the icon */
  font-variant-numeric: tabular-nums;
  text-align: left;
  line-height: 1;
}

/* ---------- mobile footer ----------
   The desktop footer's 40px link row and 3-column bottom row were sized
   for a wide viewport; at 390px the 4 big links (email/linkedin/github/
   resume) simply overflowed .site-footer's fixed height (which is also
   .footer-spacer's height — the two are read from the same
   --footer-height custom property, see the "footer" section above) and
   got clipped by its overflow:hidden. This was invisible before the
   mobile breakpoint existed because the fluid root font-size shrank
   these same rem values down along with everything else; now that the
   breakpoint freezes the root at a flat 16px (see style.css's "mobile
   breakpoint" comment), sizes here need their own explicit mobile
   values instead of inheriting the desktop ones.
   Redefining --footer-height here (rather than overriding .site-footer/
   .footer-spacer's height directly) keeps the two in sync automatically
   — exactly the reason that shared token exists in the first place. */
@media (max-width: 768px) {
  :root {
    /* 38rem/608px — was 24rem/384px, bumped again to fit .footer__headline
       (see below) stacked above the now-column links layout: headline +
       its own margin-bottom + 4 stacked links + bottom row all have to
       fit in one fixed-height box now, not just the single link row this
       token was originally sized for. */
    --footer-height: 38rem;
  }

  .footer__headline {
    margin-bottom: 5rem; /* 80px — was 12.5rem/200px on desktop, too tall a gap once the links themselves stack into a much taller column below */
    padding: 0 1.5rem; /* matches .footer__links/.footer__bottom-row's own mobile inset below */
    font-size: 2.5rem; /* 40px — was 5rem/80px, same mobile scale-down every other 80px hero title on the site already gets */
  }

  /* Watermark logo removed on mobile entirely, per direct request —
     stays desktop-only. */
  .footer__logo {
    display: none;
  }

  .footer__links {
    flex-direction: column;
    align-items: flex-start;
    gap: 1.375rem; /* 22px between stacked links — was 14px, +8px per direct request */
    padding-inline: 1.5rem; /* 24px, per direct spec — was var(--margin)/40px */
  }

  .footer__links a {
    font-size: 1.5rem; /* 24px — was 40px, too wide to fit 4-across on a phone */
  }

  /* Left column stacks back-to-top above the tagline, so "Made with
     love..." sits in the true bottom-left corner with back-to-top
     directly above it; reactions pin to the bottom-right corner,
     mirroring the tagline's row — per direct spec. Grid (not flex) so
     both left-column rows can share a column without a wrapper element
     in the HTML. */
  .footer__bottom-row {
    display: grid;
    grid-template-columns: 1fr auto;
    row-gap: 0.75rem;
    column-gap: 1rem;
    padding: 0 1.5rem; /* 24px, per direct spec — was var(--margin)/40px */
  }

  .footer__bottom-row #back-to-top {
    grid-column: 1;
    grid-row: 1;
    justify-self: start;
  }

  .footer__bottom-row p {
    grid-column: 1;
    grid-row: 2;
    justify-self: start;
  }

  .footer__bottom-row .footer__reactions {
    grid-column: 2;
    grid-row: 2;
    justify-self: end;
  }
}

/* page content sits above the fixed footer; scrolling past it simply
   uncovers the fixed footer underneath (see .site-footer / .footer-spacer
   above) — no scale or radius treatment on this wrapper.
   padding-bottom: the footer only starts becoming visible once .page's
   own (opaque) box has fully scrolled past — so this padding is what
   actually creates a fixed, always-present 200px gap of plain page
   background between the last content section and the point the footer
   starts revealing. (A margin-top on .footer-spacer was tried first and
   was wrong: .footer-spacer is a sibling AFTER .page, so margin on it
   doesn't move .page's own bottom edge/the reveal-start point at all —
   it only added 200px of dead scroll space at the very end, AFTER the
   footer was already fully revealed, which is why it only showed up
   once already scrolled to the bottom.) */
.page {
  position: relative;
  z-index: 2;
  background: var(--white);
  padding-bottom: 10rem; /* 160px — was 80px, was 200px before that */
  transition: background-color 0.4s var(--ease-out);
}

html.dark-mode .page {
  background: #0B0A0B;
}
