* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  overflow-x: hidden;
  /* .slide's cross-fade briefly has the outgoing and incoming pages both
     semi-transparent at once -- without this, the browser's default
     white canvas showed through underneath during that overlap. */
  background: #0a0c0e;
}

body {
  font-family: system-ui, sans-serif;
  color: #fff;
}

.track {
  position: relative;
  width: 100vw;
  height: 100vh;
  height: 100dvh;
}

/* Every page sits stacked in the exact same spot (rather than side by
   side, sliding into view) and is shown or hidden purely with opacity --
   a plain cross-fade instead of the old horizontal push. visibility is
   staggered off the opacity transition (see .is-active below) so an
   inactive slide still keeps rendering -- and stays clickable-through-
   proof via pointer-events -- for the full fade-out before it's actually
   hidden, and so its layout (e.g. a destination page's title, for the
   header morph in router.js) can still be measured accurately while
   invisible. */
.slide {
  position: absolute;
  inset: 0;
  /* Without a z-index, position: absolute alone does NOT give this element
     its own stacking context -- .page's z-index: -1 background layers
     would then be pulled out to the document root's stacking context
     instead of staying local, painting behind literally everything
     (including the root's own background) rather than just behind this
     page's own opaque background-color, which was hiding them entirely.
     isolation: isolate forces the local context without needing to pick
     a z-index number that has to stay clear of the header/morph-clone/
     progress-bar's own values elsewhere. */
  isolation: isolate;
  overflow-x: hidden;
  overflow-y: auto;
  /* The accordion constantly resizes content above/around the viewport as
     sections open and close; the browser's automatic scroll anchoring
     tries to compensate by adjusting scrollTop itself, which fights our
     own scroll-driven logic and causes visible jumping. */
  overflow-anchor: none;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.6s cubic-bezier(0.65, 0, 0.35, 1), visibility 0s linear 0.6s;
}

.slide.is-active {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 0.6s cubic-bezier(0.65, 0, 0.35, 1), visibility 0s linear 0s;
}

.hero {
  background-image: url("Images/Backgrounds/hero-background.jpg");
  background-size: cover;
  background-position: center;
  background-attachment: fixed;
  display: flex;
  align-items: center;
  justify-content: center;
}

@media (max-width: 768px) {
  .hero {
    background-attachment: scroll;
  }
}

/* Mobile only: shifts all the hero content up by giving the leftover
   space above/below it a 1:3 ratio instead of flex's default centered
   1:1 -- .hero-content's own natural height is unaffected, only how the
   remaining space around it is split. 1:3 halves the original (equal)
   top gap while keeping the total leftover space the same, since
   1:1 -> top=X, bottom=X (total 2X); 1:3 -> top=X/2, bottom=3(X/2)=1.5X
   (still totals 2X). Proportional rather than a fixed pixel value, so it
   holds correctly across any device height instead of just one tested
   size. .hero-overlay is position: absolute so it doesn't participate
   in this and doesn't need excluding. */
@media (max-width: 700px) {
  .hero {
    flex-direction: column;
    align-items: center;
  }

  .hero::before {
    content: "";
    flex: 1 0 0;
  }

  .hero::after {
    content: "";
    flex: 3 0 0;
  }
}

.hero-overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.8);
}

.hero-content {
  position: relative;
  text-align: center;
  padding: 20px;
}

.hero-content h1 {
  white-space: pre-line;
  font-size: clamp(2rem, 6vw, 3.5rem);
}

.start-heading {
  margin-top: 28px;
  font-size: clamp(1.25rem, 4vw, 2rem);
  font-weight: 600;
}

.start-buttons {
  margin: 20px auto 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 14px;
  width: 100%;
  max-width: 320px;
}

@media (min-width: 700px) {
  .start-buttons {
    flex-direction: row;
    max-width: none;
    justify-content: center;
    align-items: center;
  }
}

.start-btn {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 12px 24px;
  border: 2px solid #fff;
  border-radius: 6px;
  background: transparent;
  color: #fff;
  text-decoration: none;
  font: inherit;
  font-size: 1rem;
  cursor: pointer;
  transition: background-color 0.25s ease;
}

.start-btn:hover,
.start-btn:focus-visible {
  background-color: rgba(0, 0, 0, 0.5);
}

.page {
  /* Fallback paint while the background image loads (or if it fails). */
  background-color: #14181c;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 12px;
  padding: 20px;
}

/* Same left-aligned, top-anchored treatment as .page-professional (see
   below) instead of the generic .page's centered layout -- overrides
   that rule's center/justify-center/text-align-center, same reasoning:
   this page has real top-down content now, not just a centered blurb.
   Deliberately NOT zeroing out .page's own padding: 20px here (unlike
   .page-photography/.page-gallery) -- .page-professional doesn't either,
   it just inherits that 20px outer inset, and matching it exactly is the
   point. */
.page-side-projects {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  text-align: left;
  gap: 0;
}

/* Deliberately NOT flex: 1 1 auto; min-height: 0; (unlike
   .page-professional-inner). That combo forces this box to fill the
   slide's full available height regardless of content, so when content
   is taller than that height minus padding, it renders past the padded
   content-box into the reserved padding-bottom -- and since the box
   itself never exceeds the slide's own height, .slide's overflow-y:auto
   never detects anything to scroll for, so the content just sits behind
   the fixed mobile header with no way to reach it. Left at its default
   flex sizing, this box sizes to its own content instead, so tall
   content (and its padding) correctly grows .slide's scrollable height. */
.page-side-projects-inner {
  display: flex;
  flex-direction: column;
  padding: calc(var(--header-h, 110px) + 20px) 24px 20px;
}

.page-side-projects .page-title {
  flex: 0 0 auto;
}

@media (max-width: 700px) {
  /* The header sits at the bottom on mobile (see .site-header), so the
     clearance it needs flips from top to bottom here too. */
  .page-side-projects-inner {
    padding-top: 20px;
    padding-bottom: calc(var(--header-h, 70px) + 20px);
  }
}

.side-project {
  margin-top: 28px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Matches an expanded accordion item's label (.accordion-label, e.g.
   "SUMMARY" on the Professional page) exactly: same weight, size, and
   letter-spacing. Uppercase gets there via text-transform here rather
   than the accordion's own approach (storing the string already in caps
   in strings/*.json), since "Sharkwork Submarine" is a proper noun that
   should stay correctly cased in the data. */
.section-header {
  font-weight: 600;
  font-size: 0.95rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.side-project-content {
  display: flex;
  align-items: flex-start;
  gap: 24px;
}

.side-project-gif {
  display: block;
  width: 100%;
  max-width: 480px;
  height: auto;
  flex-shrink: 1;
}

/* A preview image wrapped in a link takes the image's place in the row. */
.side-project-content > a {
  display: block;
  flex: 0 1 480px;
}

.side-project-description {
  flex: 1 1 260px;
  min-width: 0;
  font-size: 1rem;
  line-height: 1.6;
  opacity: 0.85;
}

.side-project-description p {
  margin: 0 0 12px;
}

.side-project-description p:last-child {
  margin-bottom: 0;
}

.side-project-description a {
  color: #fff;
  text-decoration: underline;
  text-underline-offset: 2px;
  opacity: 0.85;
  transition: opacity 0.2s ease;
}

.side-project-description a:hover,
.side-project-description a:focus-visible {
  opacity: 1;
}

@media (max-width: 700px) {
  .side-project-content {
    flex-direction: column;
  }

  .side-project-description {
    flex-basis: auto;
  }
}

/* Same hero background image on every page, but blurred and darkened
   (90% vs. the hero's 80%) so it reads as texture behind the content
   rather than a focal image. A separate ::before layer (rather than
   blurring .page itself) keeps the blur off the actual text. z-index: -1
   keeps both layers behind .page's normal-flow content without needing
   to touch every child selector.

   position: fixed (not absolute) is deliberate: .page is also .slide,
   which scrolls its own content when there's more than fits in the
   viewport (e.g. Side Projects once its description got long). An
   absolutely positioned pseudo-element there is sized to .slide's own
   box at its CLIENT height, not its full scrollHeight -- once the user
   scrolled past that point, there was nothing behind the content at all,
   letting the raw (unblurred, undarkened) image show through. Fixed
   positioning ties both layers to the viewport instead, so they always
   cover it regardless of how tall the page's content is or how far it's
   scrolled -- a static backdrop, consistent with the hero page's own
   background-attachment: fixed. inset on ::before is pulled in past the
   edges so the blur radius never reveals a hard edge against the
   viewport. */
.page::before {
  content: "";
  position: fixed;
  inset: -24px;
  background-image: url("Images/Backgrounds/hero-background.jpg");
  background-size: cover;
  background-position: center;
  filter: blur(14px);
  z-index: -1;
}

.page::after {
  content: "";
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.9);
  z-index: -1;
}

.page-title {
  font-size: clamp(1.75rem, 6vw, 3rem);
}

.page p {
  font-size: 1rem;
  opacity: 0.85;
}

/* Overrides the generic .page rule's center/justify-center/text-align-
   center (this element also carries the .page class) -- banners need to
   stretch full width and the page needs to scroll top-down once there
   are more of them than fit in the viewport, same reasoning as
   .page-professional below. */
.page-photography {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  text-align: center;
  gap: 0;
  padding: 0;
}

.page-photography-inner {
  display: flex;
  flex-direction: column;
  padding-top: calc(var(--header-h, 110px) + 20px);
}

@media (max-width: 700px) {
  /* The header sits at the bottom on mobile now (see .site-header), so
     the clearance it needs flips from top to bottom here too. */
  .page-photography-inner {
    padding-top: 20px;
    padding-bottom: calc(var(--header-h, 70px) + 20px);
  }
}

.page-photography .page-title {
  padding: 0 24px;
  margin-bottom: 20px;
}

/* A generic, reusable banner block -- not tied to the Photography page
   specifically, so it can move along with this content if Photography
   ever collapses into Side Projects. Each banner crops its image to a
   fixed height regardless of the image's own aspect ratio; the
   --photo-focal-x/--photo-focal-y custom properties (set inline per img,
   default centered) control which part of the image stays visible in
   that crop, since object-fit: cover alone always centers it. */
.photo-banner-list {
  display: flex;
  flex-direction: column;
}

.photo-banner {
  display: block;
  width: 100%;
  height: 400px;
  overflow: hidden;
  margin-bottom: 20px;
  cursor: default;
}

/* Only the ones that are actual links (currently just the last banner --
   see index.html) get a subtle hover cue that they're clickable. */
a.photo-banner {
  cursor: pointer;
}

a.photo-banner:hover .photo-banner-img {
  transform: scale(1.03);
}

.photo-banner-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--photo-focal-x, 50%) var(--photo-focal-y, 50%);
  transition: transform 0.4s ease;
}

/* A gallery collection page, reached by clicking one of the Photography
   page's banners. Deliberately NOT given the .page class's blurred
   background-image treatment (unlike every other page) -- a busy photo
   backdrop competes with the actual gallery images for attention, so this
   gets a plain, calm dark background instead. */
.page-gallery {
  background-color: #0a0c0e;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-start;
  text-align: center;
  gap: 0;
  padding: 0;
}

.page-gallery-inner {
  display: flex;
  flex-direction: column;
  padding: calc(var(--header-h, 110px) + 20px) 24px 20px;
}

.page-gallery .page-title {
  flex: 0 0 auto;
  margin-bottom: 20px;
}

@media (max-width: 700px) {
  /* Same top/bottom clearance flip as .page-professional-inner /
     .page-photography-inner -- the header sits at the bottom on mobile. */
  .page-gallery-inner {
    padding-top: 20px;
    padding-bottom: calc(var(--header-h, 70px) + 20px);
  }
}

/* Hand-rolled masonry (no library): gallery.js sorts each image into
   whichever of these fixed-width columns is currently shortest, using
   each image's own natural aspect ratio -- so a short landscape shot and
   a tall portrait one both just stack at their own natural height,
   nothing cropped or forced into a uniform cell. Starts invisible and
   fades in once laid out, so there's no flash of the pre-layout DOM. */
.gallery-grid {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  opacity: 0;
  transition: opacity 0.4s ease;
}

.gallery-grid.is-loaded {
  opacity: 1;
}

.gallery-column {
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex: 1 1 0;
  min-width: 0;
}

/* A real <button> (not yet wired to anything) rather than a plain div --
   this is meant to open a full-screen focused view on click eventually,
   see gallery.js. */
.gallery-item {
  display: block;
  width: 100%;
  padding: 0;
  border: none;
  background: transparent;
  line-height: 0;
  cursor: pointer;
}

.gallery-item img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 4px;
}

/* Full-screen focused view, opened by clicking/tapping a gallery photo
   (see gallery.js). Built once and reused across every collection on the
   page. visibility is staggered off the opacity transition (same pattern
   as .slide) so it stays interactive/paintable through its own fade-out
   instead of vanishing mid-transition. */
body.lightbox-open {
  overflow: hidden;
}

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.95);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s ease, visibility 0s linear 0.3s;
}

.lightbox.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.3s ease, visibility 0s linear 0s;
}

.lightbox-img {
  display: block;
  max-width: 92vw;
  max-height: 92vh;
  object-fit: contain;
}

.lightbox-close,
.lightbox-nav {
  position: absolute;
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  cursor: pointer;
  line-height: 1;
  transition: background-color 0.2s ease;
}

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

.lightbox-close {
  top: 20px;
  right: 20px;
  width: 44px;
  height: 44px;
  font-size: 1.6rem;
}

.lightbox-nav {
  top: 50%;
  transform: translateY(-50%);
  width: 52px;
  height: 52px;
  font-size: 2rem;
}

.lightbox-prev {
  left: 20px;
}

.lightbox-next {
  right: 20px;
}

@media (max-width: 700px) {
  .lightbox-close {
    top: 12px;
    right: 12px;
    width: 40px;
    height: 40px;
  }

  .lightbox-nav {
    width: 44px;
    height: 44px;
    font-size: 1.6rem;
  }

  .lightbox-prev {
    left: 8px;
  }

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

/* Center and bottom-justified in the lightbox, matching .lightbox-close's
   circular style. */
/* Houses both action buttons, center/bottom-justified as one group. */
.lightbox-actions {
  position: absolute;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 12px;
}

.lightbox-visualize,
.lightbox-fractal {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.1);
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.lightbox-visualize:hover,
.lightbox-fractal:hover {
  background: rgba(255, 255, 255, 0.2);
}

/* This page is a self-contained "paged" view: the outer .slide doesn't
   scroll at all (overflow-y overridden to hidden below). Only the active
   section's own body scrolls, and accordion.js decides whether a
   wheel/touch gesture should scroll that body or advance to the next/
   previous section. Sizing is plain flexbox: the active .accordion-item
   grows (flex-grow) to fill whatever space the collapsed ones (natural
   header-only height) leave behind, with a "flex: 1; min-height: 0;"
   chain down to .accordion-body so it can shrink and scroll internally
   when content is taller than the space available. */
.page-professional {
  display: flex;
  flex-direction: column;
  /* Override the generic .page rule's center/justify-center (this element
     also carries the .page class) -- with those, .page-professional-inner
     shrinks to its content's natural width instead of stretching full
     width, which throws off the accordion's percentage-based width below. */
  align-items: stretch;
  justify-content: flex-start;
  gap: 0;
  overflow-y: hidden;
  /* accordion.js handles touch drags manually everywhere on the page
     (native scroll only ever engages over the actual scrollable element,
     which would break scrolling from elsewhere on the page); without
     this, iOS Safari's own touch scrolling still fights that handling
     even with preventDefault() in the touchmove listener. */
  touch-action: none;
}

.page-professional-inner {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  text-align: left;
  padding: calc(var(--header-h, 110px) + 20px) 24px 20px;
}

@media (max-width: 700px) {
  /* The header sits at the bottom on mobile now (see .site-header), so
     the clearance it needs flips from top to bottom here too. */
  .page-professional-inner {
    padding-top: 20px;
    padding-bottom: calc(var(--header-h, 70px) + 20px);
  }
}

.page-professional .page-title {
  flex: 0 0 auto;
}

.accordion {
  flex: 1 1 auto;
  min-height: 0;
  margin-top: 28px;
  display: flex;
  flex-direction: column;
  /* 80% of the true page/viewport width -- a percentage here would
     instead resolve against .page-professional-inner's already-padded
     content box, ending up narrower than 80% of the actual page. Capped
     at 1000px so line lengths stay readable on very large monitors.
     (min-width: 80%; max-width: 1000px would NOT work here -- per spec
     min-width wins whenever the two conflict, which is exactly the case
     above ~1250px viewports, silently defeating the cap.) */
  width: min(80vw, 1000px);
}

.accordion-item {
  /* flex-shrink: 0 -- a collapsed item must never shrink below its
     header's natural height. With shrink allowed, the flex algorithm
     would compress collapsed headers to make room for the active
     section's growth demand, clipping their text and making the next
     item's separator appear to cut through it. Only the active item
     (below) opts back into shrinking, since it has an internal scroll
     as a release valve that collapsed headers don't. */
  flex: 0 0 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  transition: flex-grow 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.accordion-item.is-open {
  flex-grow: 1;
  flex-shrink: 1;
}

/* Sections on either side of the active one are hidden individually --
   their titles are summarized together in the single .accordion-passed
   (above) / .accordion-remaining (below) row instead, so a long list of
   untouched headers doesn't crowd out the active section's content (most
   noticeable on mobile). */
.accordion-item.is-above-active,
.accordion-item.is-below-active {
  display: none;
}

/* accordion.js's height-collapse animation (see animateItemHeight) needs
   a closing item to stay rendered while its height tweens down to 0 --
   display: none can't be seen shrinking away, so this overrides it for
   the duration, then is removed once the animation completes. */
.accordion-item.is-animating {
  display: flex !important;
}

.accordion-passed:not([hidden]),
.accordion-remaining:not([hidden]) {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 44px;
  padding: 16px 4px;
  background: transparent;
  border: none;
  color: #fff;
  font: inherit;
  text-align: left;
  cursor: pointer;
  flex: 0 0 auto;
}

.accordion-passed:hover,
.accordion-passed:focus-visible,
.accordion-remaining:hover,
.accordion-remaining:focus-visible {
  background: rgba(255, 255, 255, 0.05);
}

.accordion-remaining-icon {
  width: 16.5px;
  height: 16.5px;
  flex-shrink: 0;
}

.accordion-remaining-label {
  font-weight: 600;
  font-size: calc(0.95rem - 4px);
  letter-spacing: 0.06em;
  opacity: 0.6;
}

.accordion-heading {
  flex: 0 0 auto;
  font-size: 1rem;
  font-weight: normal;
}

.accordion-header {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 44px;
  padding: 16px 4px;
  background: transparent;
  border: none;
  color: #fff;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.accordion-header:hover,
.accordion-header:focus-visible {
  background: rgba(255, 255, 255, 0.05);
}

.accordion-icon {
  width: 22px;
  height: 22px;
  flex-shrink: 0;
  transition: transform 0.3s ease, width 0.3s ease, height 0.3s ease;
}

.accordion-item.is-open .accordion-icon {
  transform: rotate(90deg);
}

/* Collapsed items read as secondary/compact next to the active section:
   icon at 75% size, label 4px smaller. */
.accordion-item:not(.is-open) .accordion-icon {
  width: 16.5px;
  height: 16.5px;
}

.accordion-label {
  font-weight: 600;
  font-size: 0.95rem;
  letter-spacing: 0.06em;
  transition: font-size 0.3s ease;
}

.accordion-item:not(.is-open) .accordion-label {
  font-size: calc(0.95rem - 4px);
}

.accordion-collapse {
  display: none;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}

.accordion-item.is-open .accordion-collapse {
  display: flex;
}

.accordion-inner {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.accordion-body {
  flex: 1 1 auto;
  min-height: 0;
  /* No align-items: center here -- centering a flex container that also
     scrolls (overflow-y: auto) makes the browser split overflow evenly
     above and below, so scrollTop: 0 doesn't land on the true start of
     the content. Centering instead lives on .accordion-body-inner, a
     plain (non-scrolling) child, which only has an effect when there's
     genuinely extra space and falls back to normal top-down flow once
     content is taller than the viewport. */
  overflow-y: auto;
  padding: 12px 4px 24px 38px;
  /* This scrolls internally (see comment above), but progress through it
     is already shown by the custom .scroll-progress bar -- a second,
     native scrollbar here would be redundant. */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* legacy Edge/IE */
}

.accordion-body::-webkit-scrollbar {
  display: none; /* Chrome, Safari, Edge (Chromium) */
}

@media (max-width: 700px) {
  .accordion-body {
    /* The 38px hanging indent (aligning text under the header label)
       eats too much of the already-narrow mobile width; drop it so the
       text uses the full accordion width instead. */
    padding-left: 4px;
  }
}

.accordion-body-inner {
  width: 100%;
  min-height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* While the item's own height is mid-animation (see animateItemHeight in
   accordion.js), centering here would recompute every frame as the
   available space grows -- the text drifting to re-center on top of the
   box itself growing reads as two competing motions at once. Anchoring
   to the top for the duration keeps it to the one motion (the box
   revealing it from the top down); centering (for short content) is
   only ever a settled-state, non-animated concern once is-animating
   is gone and the height stops moving, so there's nothing left to
   fight by then. */
.accordion-item.is-animating .accordion-body-inner {
  justify-content: flex-start;
}

.accordion-body-text {
  white-space: pre-line;
  line-height: 1.7;
  font-size: 1.05rem;
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease, transform 0.6s ease;
  transition-delay: 0.1s;
}

.accordion-item.is-open .accordion-body-text {
  opacity: 1;
  transform: translateY(0);
}

.skills-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 24px 32px;
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease, transform 0.6s ease;
  transition-delay: 0.1s;
}

.accordion-item.is-open .skills-grid {
  opacity: 1;
  transform: translateY(0);
}

@media (max-width: 700px) {
  .skills-grid {
    grid-template-columns: 1fr;
    gap: 20px;
  }
}

.skills-category-title {
  font-weight: 600;
  font-size: 0.95rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  opacity: 0.85;
  margin-bottom: 8px;
}

.skills-list,
.experience-bullets {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.skills-list li,
.experience-bullets li {
  position: relative;
  padding-left: 16px;
  line-height: 1.6;
  font-size: 1rem;
}

.skills-list li::before,
.experience-bullets li::before {
  content: "•";
  position: absolute;
  left: 0;
  opacity: 0.6;
}

.experience-list {
  display: flex;
  flex-direction: column;
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s ease, transform 0.6s ease;
  transition-delay: 0.1s;
}

.accordion-item.is-open .experience-list {
  opacity: 1;
  transform: translateY(0);
}

.experience-entry + .experience-entry {
  margin-top: 32px;
  padding-top: 32px;
  border-top: 1px solid rgba(255, 255, 255, 0.12);
}

.experience-company {
  font-size: 1.1rem;
  font-weight: 700;
}

.experience-location {
  font-weight: 400;
  opacity: 0.75;
  font-size: 0.95rem;
}

.experience-meta {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 4px 16px;
  margin-top: 4px;
  font-size: 0.95rem;
}

.experience-title {
  font-weight: 600;
  letter-spacing: 0.02em;
}

.experience-dates {
  opacity: 0.7;
  white-space: nowrap;
}

.experience-summary {
  margin-top: 12px;
  line-height: 1.7;
  opacity: 0.9;
}

.experience-bullets {
  margin-top: 16px;
  gap: 8px;
}

.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 10px;
  padding: 20px;
  background: rgba(10, 12, 14, 0.7);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

/* Mobile only: the header (by this width, just the hamburger and language
   buttons -- the pill nav is already hidden below) moves to the bottom of
   the screen instead of the top, easier to reach one-handed. Desktop is
   untouched. Dropdowns anchored to these buttons have to open upward to
   match (see .nav-hamburger-menu / .lang-menu below), and pages that
   reserve space for the header via --header-h need that space on the
   bottom edge instead of the top now -- see .page-professional-inner /
   .page-photography-inner. */
@media (max-width: 700px) {
  .site-header {
    top: auto;
    bottom: 0;
    /* center, not flex-end: the hamburger (40px), language button (36px),
       and playback controls (32px) are all different heights, and
       bottom-aligning items of differing heights makes their centers
       diverge even though their bottoms match -- centering guarantees
       one shared horizontal center line regardless of any of their
       heights, present or future. */
    align-items: center;
    /* Space-saving: no frosted bar behind the hamburger/language buttons
       on mobile -- just the buttons themselves floating over the page. */
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* Playback controls sit inside this row on mobile (see placeControls in
     music-player.js), centered between the hamburger and language button
     via margin: 0 auto below. Buttons stay a fixed 32px -- only the gap
     between them flexes, from 8px down to a 2px floor, computed in
     music-player.js's updateControlsGap() to exactly fill whatever room
     is left once the hamburger and (locale-width-dependent) language
     button are accounted for, rather than a fixed breakpoint guess. */
  #site-header .music-player-controls {
    gap: var(--controls-gap, 8px);
  }

  #site-header .music-player-control {
    width: 32px;
    height: 32px;
  }
}

.lang-switcher {
  position: relative;
  margin-left: auto;
}

.lang-toggle {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 6px;
  color: #fff;
  font: inherit;
  font-size: 0.9rem;
  cursor: pointer;
}

.lang-toggle:hover {
  background: rgba(255, 255, 255, 0.2);
}

/* Mobile only: .site-header's own background/blur is removed at this
   width (see its mobile block above), so this button sits directly over
   whatever page content is behind it instead of a frosted backdrop --
   solid instead of translucent so it stays readable regardless of what's
   behind it. */
@media (max-width: 700px) {
  .lang-toggle {
    background: #303030;
    border-color: rgba(255, 255, 255, 0.5);
  }

  .lang-toggle:hover {
    background: #404040;
  }
}

.lang-icon {
  width: 18px;
  height: 18px;
}

.lang-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  min-width: 160px;
  list-style: none;
  background: rgba(20, 20, 20, 0.95);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 6px;
  overflow: hidden;
}

@media (max-width: 700px) {
  .lang-menu {
    top: auto;
    bottom: calc(100% + 6px);
  }
}

.lang-menu li {
  padding: 10px 14px;
  font-size: 0.9rem;
  cursor: pointer;
}

.lang-menu li:hover {
  background: rgba(255, 255, 255, 0.15);
}

.lang-menu li.selected {
  font-weight: 600;
}

.page-nav {
  display: none;
  flex-wrap: wrap;
  gap: 8px;
}

body:not(.on-home) .page-nav {
  display: flex;
}

/* Below the breakpoint where the 5 pills would wrap into a second row,
   swap them for a single hamburger button + dropdown instead, so the
   header stays a single tidy row alongside the language switcher. */
@media (max-width: 700px) {
  body:not(.on-home) .page-nav {
    display: none;
  }
}

.nav-hamburger {
  position: relative;
  display: none;
}

/* Shown on the home page too here (unlike the desktop pill nav above,
   which stays home-page-free since the hero already has its own start
   buttons) -- otherwise the header's mobile row gains/loses the
   hamburger's 40px+gap the moment you navigate away from home, shifting
   the playback controls sitting next to it. Placed after the base rule
   above so it actually wins the cascade at equal specificity (source
   order, not media-query position, decides ties). */
@media (max-width: 700px) {
  .nav-hamburger {
    display: block;
  }
}

.nav-hamburger-toggle {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 6px;
  cursor: pointer;
}

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

/* Same reasoning as .lang-toggle's mobile override above. */
@media (max-width: 700px) {
  .nav-hamburger-toggle {
    background: #303030;
    border-color: rgba(255, 255, 255, 0.5);
  }

  .nav-hamburger-toggle:hover {
    background: #404040;
  }
}

.hamburger-icon {
  width: 20px;
  height: 20px;
}

.nav-hamburger-menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  min-width: 180px;
  list-style: none;
  background: rgba(20, 20, 20, 0.95);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 6px;
  overflow: hidden;
}

@media (max-width: 700px) {
  .nav-hamburger-menu {
    top: auto;
    bottom: calc(100% + 6px);
  }
}

.nav-hamburger-menu .page-nav-link {
  display: block;
  width: 100%;
  border: none;
  border-radius: 0;
  background: transparent;
  padding: 12px 14px;
}

.nav-hamburger-menu .page-nav-link:hover {
  background: rgba(255, 255, 255, 0.15);
}

.nav-hamburger-menu .page-nav-link[aria-current="page"] {
  background: rgba(255, 255, 255, 0.1);
}

.page-nav-link {
  padding: 9px 12px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 6px;
  color: #fff;
  text-decoration: none;
  font-size: 0.85rem;
  white-space: nowrap;
}

.page-nav-link:hover {
  background: rgba(255, 255, 255, 0.2);
}

.page-nav-link[aria-current="page"] {
  font-weight: 600;
  border-color: #fff;
  background: rgba(255, 255, 255, 0.2);
}

/* A vertical progress indicator for the Professional page's accordion,
   tracking scroll position against the page's full height with every
   section expanded (see accordion.js's off-screen measuring clone) --
   only shown there since it's the only page with this kind of scrollable,
   collapsible content. */
.scroll-progress {
  /* top/height are set inline by accordion.js to exactly match the
     accordion's own top edge and the bottom edge of whatever its last
     visible row is (usually .accordion-remaining), rather than an
     arbitrary fixed size unrelated to the accordion's real position. */
  position: fixed;
  right: 20px;
  width: 4px;
  z-index: 900;
  display: none;
  pointer-events: none;
}

body.on-professional .scroll-progress {
  display: block;
}

.scroll-progress-track {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.12);
  overflow: hidden;
}

.scroll-progress-fill {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0%;
  border-radius: 999px;
  background: linear-gradient(180deg, #fff, rgba(255, 255, 255, 0.55));
  transition: height 0.25s ease;
}

@media (max-width: 700px) {
  .scroll-progress {
    right: 10px;
  }
}

/* Persistent, site-wide music widget. Lives outside #track (see
   index.html) so route changes never touch it. Bottom-center on desktop
   since the header is at the top there; on mobile the header itself sits
   at the bottom (see .site-header), so this needs the same --header-h
   clearance the rest of the site uses to avoid colliding with it. */
.music-player {
  position: fixed;
  left: 50%;
  bottom: 20px;
  transform: translateX(-50%);
  z-index: 999;
  width: min(350px, calc(100vw - 32px));
}

@media (max-width: 700px) {
  .music-player {
    /* Space-saving: tucked in close to the header instead of clearing the
       whole bar. .site-header's own padding-top is 20px, which cancels
       against --header-h to land the widget's bottom edge exactly at the
       hamburger icon's top edge (see the fuller derivation in the earlier
       commit) -- the -10px pulls it 10px past that, into the icon's own
       row, for a 10px gap above the icon instead of 20px. */
    bottom: calc(var(--header-h, 80px) - 10px);
  }
}

/* Bandcamp's own size=small embed (a 350x42 bar), sitting directly in the
   widget's spot rather than behind a custom toggle -- see music-player.js.
   The glassy card styling lives here rather than on the iframe itself
   (transparent=true in the embed URL lets it show through). */
/* Styled to read as a "cap" sitting on top of .music-player-embed below
   it -- same background/border/blur, but border-bottom and the bottom
   corners are left square/open since that edge faces straight into the
   embed's own top border 2px below. */
/* The background/border/blur fade out toward the left and right rather
   than stopping at a hard rectangular edge -- softer than a flat box,
   since the text itself is much narrower than the full-width bar. A
   mask (not opacity) fades background, border, and blur together as one
   unit while keeping the text itself fully legible; a fixed 40px fade
   width (not a percentage) keeps the fade the same regardless of how
   wide the tagline text ends up being. */
.music-player-tagline {
  margin: 0 0 2px;
  padding: 0 12px;
  text-align: center;
  color: #fff;
  font-size: 0.8rem;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-bottom: none;
  border-radius: 8px 8px 0 0;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  mask-image: linear-gradient(to right, transparent, #000 40px, #000 calc(100% - 40px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 40px, #000 calc(100% - 40px), transparent);
}

.music-player-embed {
  min-height: 42px;
  background: rgba(255, 255, 255, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 8px;
  overflow: hidden;
  line-height: 0;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}

.music-player-iframe {
  border: 0;
  display: block;
}

.music-player-embed.is-expanded {
  min-height: 470px;
}

.music-player-controls {
  display: flex;
  justify-content: center;
  gap: 8px;
  /* Hidden until music-player.js has placed this row (mobile vs. desktop)
     and computed its real gap -- music-player.js loads at the end of
     <body> and runs after the browser's first paint, so without this the
     row would briefly render in its default desktop position/spacing
     and visibly snap into place once the script catches up. Layout
     space is still reserved (visibility, not display), so the hamburger
     and language button never shift when it appears. */
  visibility: hidden;
}

.music-player-controls.is-ready {
  visibility: visible;
}

/* Default location: under the embed. On mobile, music-player.js instead
   moves this row into #site-header between the hamburger and language
   buttons (see the placeControls comment there), where it becomes a
   normal flex child of that row -- margin: 0 auto centers it in
   whatever space is left between its neighbors, the same trick
   .lang-switcher already relies on (margin-left: auto) to sit at the
   row's far end regardless of content width. */
#music-player > .music-player-controls {
  margin-top: 8px;
}

/* margin-left: auto only (not margin: 0 auto) -- .lang-switcher already
   carries its own margin-left: auto (needed to push it to the row's end
   regardless of what precedes it elsewhere, e.g. the desktop pill nav).
   With both this and a margin-right: auto here, there'd be three
   auto-margin edges in the row (this element's left AND right, plus
   .lang-switcher's left) instead of two, and flexbox splits free space
   per edge, not per visual gap -- the right-hand gap silently got roughly
   double the left's, since two of those three edges landed on that side.
   Two matching edges (this margin-left, .lang-switcher's margin-left)
   split the space evenly instead. */
#site-header > .music-player-controls {
  margin-left: auto;
}

.music-player-list-wrap {
  position: relative;
}

.music-player-control {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #303030;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 999px;
  cursor: pointer;
}

.music-player-control:hover {
  background: #404040;
}

/* Track picker popup -- opens upward since the widget sits low on the
   screen (bottom-anchored on both mobile and desktop), same pattern as
   .lang-menu / .nav-hamburger-menu opening upward on mobile. Solid to
   match the controls row rather than the site's usual translucent
   dropdowns, for the same readability reasons the mobile header buttons
   went solid. */
.music-player-list {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  width: max(200px, 100%);
  max-width: calc(100vw - 32px);
  list-style: none;
  background: #303030;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 8px;
  overflow: hidden;
}

.music-player-list li {
  padding: 10px 14px;
  font-size: 0.85rem;
  cursor: pointer;
}

.music-player-list li:hover {
  background: #404040;
}

.music-player-list li.selected {
  font-weight: 600;
}

.accordion-measure-clone {
  position: fixed;
  top: 0;
  left: -99999px;
  visibility: hidden;
  pointer-events: none;
}

.morph-clone {
  position: fixed;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  margin: 0;
  pointer-events: none;
  z-index: 2000;
  white-space: nowrap;
  transition:
    left 0.6s cubic-bezier(0.65, 0, 0.35, 1),
    top 0.6s cubic-bezier(0.65, 0, 0.35, 1),
    width 0.6s cubic-bezier(0.65, 0, 0.35, 1),
    height 0.6s cubic-bezier(0.65, 0, 0.35, 1),
    font-size 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}
