@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 500 700;
  font-display: swap;
  src: url("/assets/fonts/space-grotesk-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122;
}

@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 500 700;
  font-display: swap;
  src: url("/assets/fonts/space-grotesk-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF;
}

:root {
  --bg: #0a0c0f;
  --bg-elevated: #14171c;
  --text: #edeff2;
  --text-muted: #adb3bc;
  --accent: #1f9aa3;
  --accent-strong: #5cc4cc;
  --border: #232830;
  --max-width: 72rem;
  --space: 1.5rem;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  /* Sharper, faster-settling curve for the telemetry/motorsport-inspired UI
     (project index numbers, spec readouts) -- distinct from --ease's soft
     entrance feel, used where a snappier, more "precision instrument" motion
     fits better. */
  --ease-snap: cubic-bezier(0.65, 0, 0.35, 1);
}

* { box-sizing: border-box; }

/* Native cross-page transitions for this multi-page site -- zero JS, and browsers
   without support just navigate normally (no fallback needed). Kept short and
   simple (a soft cross-fade) rather than elaborate choreography: unsupported/
   older browsers get instant navigation, so the transition should feel like a
   bonus polish layer, not something content correctness depends on. */
@view-transition {
  navigation: auto;
}

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.22s;
    animation-timing-function: var(--ease);
  }
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation: none;
  }
}

html { color-scheme: dark; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: "Space Grotesk", system-ui, -apple-system, "Segoe UI", sans-serif;
  font-weight: 500;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
}

/* Sitewide ambient starfield -- a self-hosted, seeded SVG dot tile (no external
   fetch), two layers at different scales/opacities for a subtle depth cue, and a
   very slow drift so it reads as atmosphere rather than an animation competing
   for attention. Pure background texture, same category as .hero-gradient. */
.starfield {
  position: fixed;
  inset: 0;
  /* -1, one step in front of .hero-gradient (-2): with mix-blend-mode: screen,
     the white dots punch through the gradient's color (screen only ever
     brightens) while its own transparent gaps let the gradient show normally --
     without this the opaque hero-gradient pages (home, project index) hid the
     starfield completely, the opposite of "sitewide". */
  z-index: -1;
  pointer-events: none;
  mix-blend-mode: screen;
  background-image: url("/assets/img/decor/starfield-tile.svg"), url("/assets/img/decor/starfield-tile.svg");
  background-repeat: repeat, repeat;
  background-size: 480px 480px, 800px 800px;
  background-position: 0 0, 0 0;
  opacity: 0.5;
}

@media (prefers-reduced-motion: no-preference) {
  .starfield {
    animation: starfield-drift 240s linear infinite;
  }
}

@keyframes starfield-drift {
  from { background-position: 0 0, 0 0; }
  to { background-position: -480px 480px, 800px -800px; }
}

a {
  color: var(--accent);
  transition: color 0.15s ease;
}

a:hover, a:focus-visible {
  color: var(--accent-strong);
}

h1, h2, h3 {
  line-height: 1.1;
  font-weight: 700;
  font-family: "Space Grotesk", system-ui, sans-serif;
  letter-spacing: -0.02em;
}

h1 { font-size: clamp(2.25rem, 5vw + 1rem, 3.75rem); }
h2 {
  font-size: clamp(1.5rem, 2.4vw + 1rem, 2rem);
  margin-top: 3rem;
  display: inline-block;
  padding: 0.1rem 0.6rem;
  margin-left: -0.6rem;
  border-left: 2px solid transparent;
  transition: border-color 0.2s var(--ease-snap), color 0.2s var(--ease-snap);
}

h2:hover {
  border-left-color: var(--accent);
}

/* The per-page entrance reveal (nav, h1, first content blocks) used to live
   here as a CSS-only fade-up/fade-down applied per element via nth-of-type
   delays -- replaced by app/src/page-entrance.js, one authored GSAP timeline
   with real relative offsets between elements instead of independent
   per-element delays, applied sitewide via base.njk instead of only on pages
   that happened to have JS-driven choreography (previously just the homepage).
   gsap.from() only hides an element once it actually runs, so a blocked/failed
   script load leaves the page in its normal, fully visible state -- no CSS
   fallback is needed for that failure mode. */

.skip-link {
  position: absolute;
  top: -3rem;
  left: 0.75rem;
  z-index: 50;
  background: var(--bg-elevated);
  color: var(--text);
  padding: 0.6rem 1rem;
  border-radius: 0.4rem;
  border: 1px solid var(--border);
  text-decoration: none;
  transition: top 0.15s ease;
}

.skip-link:focus {
  top: 0.75rem;
}

.site-header {
  position: sticky;
  top: 0;
  z-index: 20;
  width: 100%;
  background: rgba(10, 12, 15, 0.78);
  backdrop-filter: blur(14px) saturate(160%);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  border-bottom: 1px solid var(--border);
  transition: transform 0.3s var(--ease);
}

.site-header.is-hidden {
  transform: translateY(-100%);
}

.site-header-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space);
  max-width: var(--max-width);
  margin: 0 auto;
  padding: var(--space);
}

.site-header .brand {
  font-family: "Space Grotesk", system-ui, sans-serif;
  font-weight: 700;
  text-decoration: none;
  color: var(--text);
}

.site-header nav {
  display: flex;
  gap: 1.25rem;
  align-items: center;
}

.site-header nav a {
  text-decoration: none;
  color: var(--text-muted);
  position: relative;
  padding: 0.35rem 0.6rem;
  margin: -0.35rem -0.6rem;
  transition: color 0.15s var(--ease-snap);
}

.site-header nav a::after {
  content: "";
  position: absolute;
  left: 0.6rem;
  right: calc(100% - 0.6rem);
  bottom: 0.2rem;
  height: 1px;
  background: var(--accent);
  transition: right 0.25s var(--ease-snap);
}

.site-header nav a[aria-current="page"] {
  color: var(--text);
}

.site-header nav a[aria-current="page"]::after {
  right: 0.6rem;
}

.site-header nav a:hover,
.site-header nav a:focus-visible {
  color: var(--text);
}

.site-header nav a:hover::after,
.site-header nav a:focus-visible::after {
  right: 0.6rem;
}

.lang-switch {
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.15rem 0.6rem;
  font-size: 0.85rem;
  transition: border-color 0.15s ease;
}

.lang-switch:hover {
  border-color: var(--accent);
}

main {
  max-width: var(--max-width);
  margin: 0 auto;
  padding: 0 var(--space) calc(var(--space) * 3);
}

.site-footer {
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  text-align: center;
  padding: var(--space);
  margin-top: calc(var(--space) * 2);
}

.media-gallery {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1rem;
  padding: 0;
}

.media-gallery img,
.media-gallery video {
  width: 100%;
  height: auto;
  border-radius: 0.5rem;
  border: 1px solid var(--border);
}

.media-gallery-caption {
  margin: 0.6rem 0 0;
  text-align: center;
}

.lightbox-overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 3rem 1.5rem;
  background: rgba(0, 0, 0, 0.88);
}

.lightbox-overlay.is-open {
  display: flex;
}

.lightbox-img {
  max-width: 100%;
  max-height: 100%;
  border-radius: 0.5rem;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}

.lightbox-close {
  position: absolute;
  top: 1rem;
  right: 1.25rem;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 0.3rem;
  border: 1px solid rgba(155, 232, 255, 0.3);
  background: rgba(20, 23, 28, 0.7);
  color: var(--text);
  font-size: 1.75rem;
  line-height: 1;
  cursor: pointer;
  transition: border-color 0.2s var(--ease-snap), background 0.2s var(--ease-snap);
}

.lightbox-close:hover,
.lightbox-close:focus-visible {
  border-color: var(--accent);
  background: rgba(28, 32, 39, 0.85);
}

.project-hero img,
.project-hero video {
  width: 100%;
  height: auto;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
}

.project-meta-tag {
  margin: 0 0 0.5rem;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-strong);
  transform-origin: left center;
}

@media (prefers-reduced-motion: no-preference) {
  /* p.project-meta-tag (not just .project-meta-tag) to match the specificity of
     the generic ".project > p" fade-up rule above -- same specificity, later in
     the file, so this wins instead of being silently overridden by it. */
  p.project-meta-tag {
    animation: telemetry-snap-in 0.35s var(--ease-snap) both;
  }
}

@keyframes telemetry-snap-in {
  from { opacity: 0; transform: scaleX(0.7); clip-path: inset(0 60% 0 0); }
  to { opacity: 1; transform: scaleX(1); clip-path: inset(0 0 0 0); }
}

.affiliation-logos {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1.25rem;
  list-style: none;
  padding: 0;
  margin: 0 0 1.5rem;
}

.affiliation-logos img {
  height: 2.5rem;
  width: auto;
  max-width: 8rem;
  object-fit: contain;
  border-radius: 0.35rem;
}

/* Transparent PNG logos (EPF's magenta, RATP, Harvard...) nearly vanish on
   the dark background; same white chip the project cards already use. */
.affiliation-logos img[src$=".png"] {
  background: #fff;
  padding: 0.2rem 0.35rem;
}

.affiliation-logos a {
  display: block;
  opacity: 0.85;
  transition: opacity 0.15s ease;
}

.affiliation-logos a:hover,
.affiliation-logos a:focus-visible {
  opacity: 1;
}

.scroll-stage {
  position: relative;
  height: 400vh;
  margin: 0 calc(-1 * var(--space)) 2rem;
}

.scroll-stage-sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.scroll-stage-sticky video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.scroll-hint {
  position: absolute;
  left: 50%;
  bottom: 2rem;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  width: 3rem;
  height: 3rem;
  border-radius: 0.3rem;
  background: rgba(20, 23, 28, 0.7);
  border: 1px solid rgba(155, 232, 255, 0.3);
  color: var(--text);
  font-size: 1.25rem;
  text-decoration: none;
  transition: border-color 0.2s var(--ease-snap), background 0.2s var(--ease-snap);
  animation: scroll-hint-bounce 2s ease-in-out infinite;
}

.scroll-hint:hover,
.scroll-hint:focus-visible {
  border-color: var(--accent);
  background: rgba(28, 32, 39, 0.85);
}

@keyframes scroll-hint-bounce {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50% { transform: translateX(-50%) translateY(-8px); }
}

html {
  scroll-behavior: smooth;
  scroll-padding-top: 5rem;
}

@media (max-width: 40rem) {
  html {
    scroll-padding-top: 8rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  .scroll-stage {
    height: auto;
    margin: 0 0 2rem;
  }

  .scroll-stage-sticky {
    position: static;
    height: auto;
  }

  .scroll-stage-sticky video {
    height: auto;
  }

  .scroll-hint {
    display: none;
  }
}

.download-list {
  padding-left: 0;
  list-style: none;
}

.doc-details {
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  margin-bottom: 0.75rem;
  padding: 0.75rem 1rem;
  background: var(--bg-elevated);
}

.doc-details summary {
  cursor: pointer;
  font-weight: 600;
}

.doc-details[open] summary {
  margin-bottom: 0.75rem;
}

.doc-details img {
  max-width: 100%;
  height: auto;
  display: block;
  border-radius: 0.5rem;
  border: 1px solid var(--border);
}

.doc-preview-pdf {
  width: 100%;
  height: 70vh;
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  background: #fff;
}

.doc-links {
  margin-top: 0.75rem;
  font-size: 0.9rem;
}

.experience-item {
  border: 1px solid var(--border);
  border-radius: 0.5rem;
  margin-bottom: 1rem;
  padding: 1.25rem 1.5rem;
  background: var(--bg-elevated);
}

.experience-item summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  cursor: pointer;
  list-style: none;
}

.experience-item summary::-webkit-details-marker {
  display: none;
}

.experience-item summary::marker {
  content: "";
}

.experience-item summary h2 {
  margin: 0;
  font-size: 1.2rem;
  font-weight: 600;
}

/* The native disclosure marker is hidden above; this chevron gives sighted
   users back the open/closed cue (points right when closed, down when open). */
.experience-item summary h2::before {
  content: "";
  display: inline-block;
  width: 0.45em;
  height: 0.45em;
  margin: 0 0.7em 0.12em 0.1em;
  border-right: 2px solid var(--accent-strong);
  border-bottom: 2px solid var(--accent-strong);
  transform: rotate(-45deg);
  transition: transform 0.2s var(--ease-snap);
}

.experience-item[open] summary h2::before {
  transform: rotate(45deg);
}

/* The logos' default bottom margin (meant for project pages) pushed them
   12px above the title's centre line and padded every closed card. */
.experience-item summary .affiliation-logos {
  margin: 0;
}

.experience-item[open] summary {
  margin-bottom: 1rem;
}

/* Instrument-panel kit: replaces the frosted-glass-pill recipe (gradient fill +
   backdrop-filter blur) that used to be copy-pasted across every interactive
   element on the site -- the single most generic "AI portfolio template" tell
   found in the v3 audit. Sharp corners instead of a pill, a flat dark fill
   instead of glass, corner brackets that snap into view on hover/focus instead
   of a blur, --ease-snap instead of a generic ease. */
.project-cta a,
[data-glass-cta] {
  display: inline-block;
  position: relative;
  background: rgba(20, 23, 28, 0.7);
  border: 1px solid rgba(155, 232, 255, 0.3);
  color: var(--text);
  padding: 0.75rem 1.5rem;
  border-radius: 0.25rem;
  text-decoration: none;
  font-weight: 600;
  transition: background 0.2s var(--ease-snap), transform 0.2s var(--ease-snap), border-color 0.2s var(--ease-snap);
}

.project-cta a::before,
.project-cta a::after,
[data-glass-cta]::before,
[data-glass-cta]::after {
  content: "";
  position: absolute;
  width: 0.55rem;
  height: 0.55rem;
  border: 1px solid var(--accent);
  opacity: 0;
  transition: opacity 0.2s var(--ease-snap);
}

.project-cta a::before,
[data-glass-cta]::before {
  top: -1px;
  left: -1px;
  border-right: none;
  border-bottom: none;
}

.project-cta a::after,
[data-glass-cta]::after {
  bottom: -1px;
  right: -1px;
  border-left: none;
  border-top: none;
}

.project-cta a:hover,
.project-cta a:focus-visible,
[data-glass-cta]:hover,
[data-glass-cta]:focus-visible {
  background: rgba(28, 32, 39, 0.85);
  color: var(--text);
  border-color: var(--accent);
  transform: translateY(-2px);
}

.project-cta a:hover::before,
.project-cta a:hover::after,
.project-cta a:focus-visible::before,
.project-cta a:focus-visible::after,
[data-glass-cta]:hover::before,
[data-glass-cta]:hover::after,
[data-glass-cta]:focus-visible::before,
[data-glass-cta]:focus-visible::after {
  opacity: 1;
}

.project-cta a:active,
[data-glass-cta]:active {
  transform: translateY(0);
}

.project-pager {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  margin: 2.5rem 0 1.5rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border);
}

.project-pager-link {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  max-width: 45%;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 0.9rem;
  transition: color 0.15s var(--ease-snap);
}

.project-pager-link:hover,
.project-pager-link:focus-visible {
  color: var(--accent-strong);
}

.project-pager-next {
  margin-left: auto;
  text-align: right;
}

.project-pager-arrow {
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  flex-shrink: 0;
}

.project-pager-title {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.filter-pills {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0 0 1.75rem;
}

.filter-pill {
  font: inherit;
  font-size: 0.9rem;
  color: var(--text-muted);
  background: transparent;
  border: 1px solid var(--border);
  border-radius: 0.25rem;
  padding: 0.45rem 1rem;
  cursor: pointer;
  transition: color 0.15s var(--ease-snap), border-color 0.2s var(--ease-snap), background 0.2s var(--ease-snap);
}

.filter-pill:hover {
  color: var(--text);
  border-color: var(--accent);
}

.filter-pill.is-active {
  color: var(--text);
  border-color: var(--accent);
  background: rgba(95, 208, 255, 0.1);
}

.filter-empty {
  color: var(--text-muted);
  font-style: italic;
}

.project-cards {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1.25rem;
  padding: 0;
}

.project-cards li.is-hidden {
  display: none;
}

.project-cards li {
  border: 1px solid var(--border);
  border-radius: 0.75rem;
  padding: 1.25rem;
  background: var(--bg-elevated);
  overflow: hidden;
  position: relative;
  transition: box-shadow 0.35s var(--ease), border-color 0.35s ease;
}

.project-cards li::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at var(--glow-x, 50%) var(--glow-y, 50%), rgba(95, 208, 255, 0.28), transparent 60%);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
  z-index: 1;
}

.project-cards li:hover {
  transform: translateY(-6px);
  border-color: var(--accent);
  box-shadow: var(--shadow-x, 0px) var(--shadow-y, 18px) 36px rgba(0, 0, 0, 0.55);
}

.project-cards li:hover::before {
  opacity: 1;
}

.project-cards a {
  text-decoration: none;
  color: var(--text);
  display: block;
}

.project-card-thumb img {
  transition: transform 0.5s var(--ease);
}

.project-cards li:hover .project-card-thumb img {
  transform: scale(1.06);
}

.project-cards li:hover .project-card-tag {
  border-color: var(--accent);
  transform: translateY(-1px);
}

.project-card-title {
  display: inline-block;
  transition: color 0.15s ease;
}

@media (prefers-reduced-motion: no-preference) {
  .reveal {
    opacity: 0;
    transform: translateY(24px);
    transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
  }

  .reveal.is-visible {
    opacity: 1;
    transform: translateY(0);
  }

  .project-cards li:nth-child(1) { transition-delay: 0s; }
  .project-cards li:nth-child(2) { transition-delay: 0.06s; }
  .project-cards li:nth-child(3) { transition-delay: 0.12s; }
  .project-cards li:nth-child(4) { transition-delay: 0.18s; }
  .project-cards li:nth-child(5) { transition-delay: 0.24s; }
  .project-cards li:nth-child(6) { transition-delay: 0.3s; }
  .project-cards li:nth-child(n+7) { transition-delay: 0.34s; }
}

.project-card-media {
  position: relative;
  width: calc(100% + 2.5rem);
  margin: -1.25rem -1.25rem 1rem;
  overflow: hidden;
}

.project-card-thumb,
.project-card-thumb picture,
.project-card-thumb img {
  display: block;
  width: 100%;
  height: 10rem;
  object-fit: cover;
}

.project-card-logos {
  position: absolute;
  bottom: 0.5rem;
  right: 0.5rem;
  display: flex;
  gap: 0.35rem;
  z-index: 1;
}

.project-card-logos img {
  /* 1.5rem was fine for simple wordmarks (Zenodo) but shrank a detailed
     crest/wordmark (Harvard, RATP) into an illegible blur. */
  height: 2.25rem;
  width: auto;
  max-width: 4rem;
  object-fit: contain;
  background: #fff;
  border-radius: 0.3rem;
  padding: 0.2rem;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}

.project-card-tag {
  position: absolute;
  top: 0.6rem;
  left: 0.6rem;
  z-index: 1;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--accent-strong);
  background: rgba(10, 12, 15, 0.65);
  border: 1px solid rgba(155, 232, 255, 0.25);
  border-radius: 0.3rem;
  padding: 0.15rem 0.45rem;
  backdrop-filter: blur(4px);
  transition: border-color 0.2s var(--ease-snap), transform 0.2s var(--ease-snap);
  -webkit-backdrop-filter: blur(4px);
}

.project-card-title {
  font-weight: 700;
}

.page {
  position: relative;
}

/* Homepage intro: LinkedIn portrait beside the opening paragraphs (Élise Levet's
   pattern: the face, with the name in its alt, next to "I'm ..."). */
.about-intro {
  display: flex;
  align-items: flex-start;
  gap: 2rem;
  max-width: 70ch;
}

.about-intro p:first-child {
  margin-top: 0;
}

.about-portrait {
  flex: 0 0 auto;
  width: clamp(8rem, 18vw, 13rem);
  height: auto;
  aspect-ratio: 1;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
  object-fit: cover;
}

@media (max-width: 40rem) {
  .about-intro {
    flex-direction: column;
    gap: 1.25rem;
  }

  .about-portrait {
    width: 9rem;
  }
}

/* Homepage highlights: a flight-log ledger, not boxes. Local rules: no fills,
   hairline rows only; teal at rest = tag + tick; every link underlined in
   text colour with a teal underline; corner brackets stay for the CTAs and
   the looking-for callout. */
.highlight-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
  gap: 1.75rem 2rem;
  list-style: none;
  padding: 0;
  margin: 1.5rem 0 2rem;
}

.highlight-card {
  position: relative;
  padding-top: 0.9rem;
  border-top: 1px solid var(--border);
}

.highlight-card::before {
  content: "";
  position: absolute;
  top: -1px;
  left: 0;
  width: 1.5rem;
  height: 1px;
  background: var(--accent-strong);
  transition: width 0.35s var(--ease-snap);
}

.highlight-card:hover::before,
.highlight-card:focus-within::before {
  width: 100%;
}

.highlight-tag {
  margin: 0 0 0.4rem;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-strong);
}

.highlight-card h3 {
  margin: 0 0 0.4rem;
  font-size: 1.15rem;
  line-height: 1.3;
  text-wrap: balance;
}

/* h3 + p, not a bare p: the bare selector used to outrank .highlight-tag and
   silently turned every tag grey. */
.highlight-card h3 + p {
  margin: 0;
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--text-muted);
}

.highlight-card a {
  color: var(--text);
  text-decoration-color: var(--accent);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
}

.highlight-card a:hover,
.highlight-card a:focus-visible {
  color: var(--accent-strong);
}

@media (prefers-reduced-motion: reduce) {
  .highlight-card::before {
    transition: none;
  }
}

.looking-for {
  position: relative;
  max-width: 70ch;
  margin: 2rem 0;
  padding: 1.25rem 1.5rem;
  border: 1px solid rgba(155, 232, 255, 0.3);
  border-radius: 0.25rem;
  background: rgba(20, 23, 28, 0.55);
}

/* Same corner brackets as the CTAs, always on here: the one "open channel"
   block on the page. */
.looking-for::before,
.looking-for::after {
  content: "";
  position: absolute;
  width: 0.7rem;
  height: 0.7rem;
  border: 1px solid var(--accent);
}

.looking-for::before {
  top: -1px;
  left: -1px;
  border-right: none;
  border-bottom: none;
}

.looking-for::after {
  bottom: -1px;
  right: -1px;
  border-left: none;
  border-top: none;
}

.looking-for h2 {
  margin: 0 0 0.75rem;
  font-size: 1.35rem;
}

.looking-for p:last-child {
  margin-bottom: 0;
}

.skill-line {
  font-size: 0.92rem;
  color: var(--text-muted);
}

/* Readable measure: across the 72rem container a paragraph ran ~150
   characters per line on desktop, about twice the comfortable 60 to 75. */
.page > p:not(.project-cta),
.project > p:not(.project-cta),
.page > ul:not([class]),
.project > ul:not([class]) {
  max-width: 70ch;
}

.hero-liquid-stage {
  position: relative;
  min-height: clamp(9rem, 22vw, 15rem);
  display: flex;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3rem);
}

.hero-liquid-stage h1 {
  position: relative;
  z-index: 1;
  margin: 0;
  pointer-events: none;
  flex: 0 0 auto;
}

.hero-liquid-mount {
  /* No mask-image here on purpose: that circular fade existed to hide the
     edge of the old bright Lightformer backdrop (the "white halo"), which
     GlassHero no longer renders -- the medallion's own rounded-square shape
     is now the only visible edge. A soft teal contact-shadow replaces it for
     a touch of lift instead of a stark glow. */
  flex: 0 0 auto;
  width: clamp(9rem, 16vw, 15rem);
  height: clamp(9rem, 16vw, 15rem);
  pointer-events: none;
  opacity: 0;
  transition: opacity 1.2s var(--ease);
  filter: drop-shadow(0 0.5rem 1.5rem rgba(31, 154, 163, 0.35));
}

.hero-liquid-mount.is-ready {
  opacity: 1;
}

@media (max-width: 48rem) {
  .hero-liquid-stage {
    gap: 1rem;
  }

  .hero-liquid-mount {
    /* On desktop the mount comes first in markup and sits to the H1's left;
       on mobile Ruben asked for it on the right instead. flex: 1 1 auto (the
       H1 next to it stays flex: 0 0 auto) lets it fill whatever width the
       heading doesn't need, up to a cap, instead of a fixed size that
       ignores how much room is actually free. */
    order: 1;
    flex: 1 1 auto;
    width: auto;
    min-width: 5rem;
    max-width: 10rem;
    aspect-ratio: 1;
    height: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero-liquid-mount {
    display: none;
  }
}

.hero-gradient {
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  overflow: hidden;
  background: linear-gradient(135deg, var(--grad-a, #0b0d10) 0%, var(--grad-b, #14202b) 50%, var(--grad-c, #1f3a4d) 100%);
  background-size: 200% 200%;
  animation: hero-gradient-shift 18s ease-in-out infinite;
}

.hero-gradient::before {
  content: "";
  position: absolute;
  inset: -20%;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  opacity: 0.05;
  mix-blend-mode: overlay;
}

.hero-gradient::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at var(--mx, 50%) var(--my, 40%), rgba(255, 255, 255, 0.045), transparent 34%);
}

@keyframes hero-gradient-shift {
  0%, 100% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
}

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

  .hero-gradient::after {
    display: none;
  }
}

@media (max-width: 40rem) {
  .site-header-inner {
    position: relative;
    flex-direction: column;
    align-items: flex-start;
    padding: 0.85rem 1rem;
    gap: 0.5rem;
  }

  /* Pinned next to the brand name instead of wrapping with the nav links --
     it used to be the last item inside <nav>, so on a narrow viewport it
     could end up stranded alone on its own third line once the other
     five links had already wrapped across two. `.site-header nav a` (base
     rule, two type selectors) otherwise outweighs a single-class selector
     here regardless of source order, which is why `.lang-switch` alone
     silently lost this fight the first time. */
  .site-header nav a.lang-switch {
    position: absolute;
    top: 0.85rem;
    right: 1rem;
  }

  /* All five sections on one row from ~360px up (measured: ~290px of text
     at this size); space-between spreads them edge to edge on wider phones,
     and the wrap only kicks in on the rare narrower screen. */
  .site-header nav {
    align-self: stretch;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.25rem 0.6rem;
  }

  .site-header nav a {
    font-size: 0.85rem;
  }

  .doc-preview-pdf {
    display: none;
  }

  .doc-links a {
    display: inline-block;
    padding: 0.6rem 1.1rem;
    border-radius: 999px;
    background: var(--bg-elevated);
    border: 1px solid var(--border);
    margin: 0.25rem 0.35rem 0.25rem 0;
  }
}

/* The one deliberately oversized typographic moment on the site (the audit
   found none anywhere -- every heading capped at the same ~60px h1 scale).
   Real, sourced numbers only (see stats front-matter on the richest project
   pages) -- never a decorative statistic. */
.stat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 2rem;
  margin: 3rem 0;
  padding: 2rem 0;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}

/* Two columns on phones: one figure per row turned Icarus's six stats into
   almost two screens of scrolling. */
@media (max-width: 40rem) {
  .stat-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1.5rem 1rem;
  }

  .stat-grid .stat-value {
    font-size: 1.75rem;
  }

  .stat-grid .stat-label {
    font-size: 0.7rem;
  }
}

.stat-value {
  display: block;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: clamp(2.75rem, 3vw + 1.5rem, 4.5rem);
  font-weight: 700;
  line-height: 1;
  color: var(--accent-strong);
  letter-spacing: -0.02em;
}

.stat-label {
  display: block;
  margin-top: 0.5rem;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.8rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.stl-viewer-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1rem;
  margin: 2rem 0;
}

.stl-viewer-mount {
  position: relative;
  aspect-ratio: 4 / 3;
  border-radius: 0.75rem;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  overflow: hidden;
}

.stl-viewer-mount canvas {
  cursor: grab;
}

.stl-viewer-mount canvas:active {
  cursor: grabbing;
}

.stl-viewer-label {
  position: absolute;
  left: 0.75rem;
  bottom: 0.6rem;
  font-size: 0.8rem;
  color: var(--text-muted);
  pointer-events: none;
}

/* Sitewide custom cursor (assets/js/cursor.js): a crosshair of four corner
   brackets. Only ever created on fine-pointer/hover-capable devices, so this
   CSS has no effect (and .has-custom-cursor is never added) on touch. */
body.has-custom-cursor,
body.has-custom-cursor a,
body.has-custom-cursor button,
body.has-custom-cursor [data-glass-cta] {
  cursor: none;
}

.cursor-dot {
  position: fixed;
  top: 0;
  left: 0;
  width: 28px;
  height: 28px;
  pointer-events: none;
  z-index: 200;
  opacity: 0;
  /* No transform declared here at all, and no transform transition while free-
     following -- JS sets `transform: translate(x,y) translate(-50%,-50%)`
     directly every pointer move, and must stay instant (1:1 with the real
     cursor position), not eased through a moving target. Position (this
     element, JS-driven) and idle rotation (.cursor-dot-spin, CSS-driven) are on
     two different elements on purpose: a CSS animation and inline JS both
     targeting `transform` on the *same* element conflict -- the animation wins
     while active, so the position JS was silently setting was getting
     overridden every 6s cycle, making the dot appear to jump toward the
     top-left corner and back instead of following the mouse. */
  transition: width 0.2s var(--ease-snap), height 0.2s var(--ease-snap), opacity 0.2s var(--ease-snap);
}

.cursor-dot.is-hovering {
  /* Only the discrete hover-snap (jumping to frame a newly-hovered element)
     gets an eased transform -- easing helps there, unlike continuous tracking. */
  transition: width 0.2s var(--ease-snap), height 0.2s var(--ease-snap), opacity 0.2s var(--ease-snap),
    transform 0.2s var(--ease-snap);
}

.cursor-dot.is-active {
  opacity: 1;
}

.cursor-dot-spin {
  position: absolute;
  inset: 0;
}

.cursor-dot span {
  position: absolute;
  width: 8px;
  height: 8px;
  border: 1.5px solid var(--accent);
}

.cursor-dot span:nth-child(1) { top: 0; left: 0; border-right: none; border-bottom: none; }
.cursor-dot span:nth-child(2) { top: 0; right: 0; border-left: none; border-bottom: none; }
.cursor-dot span:nth-child(3) { bottom: 0; right: 0; border-left: none; border-top: none; }
.cursor-dot span:nth-child(4) { bottom: 0; left: 0; border-right: none; border-top: none; }

@media (prefers-reduced-motion: no-preference) {
  .cursor-dot:not(.is-hovering) .cursor-dot-spin {
    animation: cursor-spin 6s linear infinite;
  }
}

@keyframes cursor-spin {
  to { transform: rotate(360deg); }
}

/* Always tracks the real pointer position, even while .cursor-dot's brackets
   are snapped to frame a large hovered target (a whole project card) -- Ruben's
   report: once that frame gets big, the brackets alone no longer show where
   the mouse actually is inside it. A separate element (see cursor.js) with its
   own transform, never touched by anything else, so no transform-conflict risk. */
.cursor-center-dot {
  position: fixed;
  top: 0;
  left: 0;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--accent-strong);
  pointer-events: none;
  z-index: 201;
  opacity: 0;
  transition: opacity 0.2s var(--ease-snap);
}

.cursor-center-dot.is-active {
  opacity: 1;
}

/* Invader mini-game (app/src/page-entrance.js): a couple of ambient sprites
   (the pixel invader, or a hand-drawn pixel icon for another project) idle-bob
   on the page, placed clear of every clickable element; clicking one is a hit
   (+1, removed), clicking elsewhere fires a decorative shot. Score badge + a
   one-time 20-point easter egg round it out. */
.invader-sprite {
  /* No centering transform here -- GSAP (xPercent/yPercent in
     page-entrance.js) owns this element's transform once spawned. The idle
     bob keyframe below lives on the *inner* .invader-sprite-bob div rather
     than here, for the same reason the cursor's spin was split onto its own
     inner element: a CSS animation and GSAP both targeting `transform` on the
     same node fight, and CSS wins while running, silently overriding GSAP's
     centering. */
  position: fixed;
  width: 40px;
  height: 40px;
  z-index: 220;
  cursor: pointer;
  color: var(--accent-strong);
}

/* Rare F1 bonus sprite (checkered flag, worth more points) -- a pulsing glow
   marks it as special/rarer at a glance, distinct from the regular sprites'
   plain idle bob. */
.invader-sprite.is-bonus {
  filter: drop-shadow(0 0 6px rgba(244, 244, 245, 0.7));
}

@media (prefers-reduced-motion: no-preference) {
  .invader-sprite.is-bonus {
    animation: invader-bonus-pulse 1s ease-in-out infinite;
  }
}

@keyframes invader-bonus-pulse {
  50% { filter: drop-shadow(0 0 12px rgba(244, 244, 245, 0.95)); }
}

.invader-sprite-bob {
  display: block;
  width: 100%;
  height: 100%;
}

@media (prefers-reduced-motion: no-preference) {
  .invader-sprite-bob {
    animation: invader-bob 2.4s ease-in-out infinite;
  }
}

@keyframes invader-bob {
  50% { transform: translateY(-8px); }
}

.invader-sprite-bob svg {
  /* No `fill` here on purpose: each project icon bakes its own color(s)
     straight into the SVG markup as presentation attributes on <g> (see
     spriteSvg() in page-entrance.js) so every icon reads as its own thing
     (the rocket's purple, the cipher's gold, ...) instead of one uniform
     tint -- a `fill` CSS declaration on an ancestor would win over those
     presentation attributes and flatten every icon back to one color. The
     plain invader still resolves to the accent color above because its own
     baked-in fill value is literally "currentColor". */
  width: 100%;
  height: 100%;
  shape-rendering: crispEdges;
  /* Stacked 1px drop-shadows trace a dark outline around each sprite's
     silhouette (drop-shadow follows alpha, unlike box-shadow) -- gives every
     icon a "sticker" edge against any background (photo thumbnail, card,
     starfield) instead of a flat color blob with no definition, the classic
     Win98/98-icon look Ruben asked for. */
  filter:
    drop-shadow(1px 0 0 #05060a)
    drop-shadow(-1px 0 0 #05060a)
    drop-shadow(0 1px 0 #05060a)
    drop-shadow(0 -1px 0 #05060a);
}

.invader-plus-one {
  position: fixed;
  z-index: 260;
  pointer-events: none;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-weight: 700;
  font-size: 1rem;
  color: var(--accent-strong);
}

.invader-bolt {
  position: fixed;
  width: 3px;
  height: 16px;
  pointer-events: none;
  z-index: 250;
  background: var(--accent);
}

.invader-score {
  position: fixed;
  right: 1rem;
  bottom: 1rem;
  z-index: 230;
  padding: 0.3rem 0.6rem;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.8rem;
  letter-spacing: 0.04em;
  color: var(--accent-strong);
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  opacity: 0;
  transition: opacity 0.3s var(--ease-snap);
}

.invader-score.is-visible {
  opacity: 1;
}

.invader-easter-egg {
  position: fixed;
  inset: 0;
  z-index: 300;
  overflow: hidden;
  background: rgba(10, 12, 15, 0.92);
}

.invader-easter-egg-title {
  position: absolute;
  top: 30%;
  left: 50%;
  transform: translateX(-50%);
  text-align: center;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: clamp(1.5rem, 3vw + 1rem, 2.75rem);
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--accent-strong);
  white-space: nowrap;
}

.invader-easter-egg-rocket {
  /* No centering transform here -- GSAP (xPercent in page-entrance.js) owns
     this element's transform once the launch sequence starts, same reason as
     every other GSAP-animated sprite in this file. */
  position: absolute;
  left: 50%;
  bottom: 10%;
  width: 110px;
  height: 121px;
  color: var(--accent-strong);
}

.invader-easter-egg-rocket svg {
  width: 100%;
  height: 100%;
  shape-rendering: crispEdges;
}

/* Session-gated opening sequence (app/src/page-entrance.js), homepage only,
   once per session. clip-path is animated by GSAP alone -- nothing else ever
   touches it on this element, so there's no risk of the transform/opacity
   conflict this file has hit twice already (cursor idle-spin, invader bob). */
.intro-overlay {
  position: fixed;
  inset: 0;
  z-index: 400;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg);
  clip-path: inset(0 0 0 0);
}

.intro-overlay-panel {
  text-align: center;
}

.intro-overlay-label {
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.8rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 1.25rem;
}

.intro-overlay-bar {
  position: relative;
  width: 160px;
  height: 2px;
  margin: 0 auto;
  background: var(--border);
  overflow: hidden;
}

.intro-overlay-bar-fill {
  position: absolute;
  inset: 0;
  width: 40%;
  background: var(--accent-strong);
  transform: translateX(-100%);
}

/* Indeterminate on purpose -- there's no real byte-progress to report (see
   the comment on showOpeningSequence()), so a scanning bar that keeps moving
   until the real "hero is ready" signal fires is the honest version of this,
   not a determinate fill implying a percentage nothing is actually tracking. */
@media (prefers-reduced-motion: no-preference) {
  .intro-overlay-bar-fill {
    animation: intro-scan 1.1s ease-in-out infinite;
  }
}

@keyframes intro-scan {
  50% { transform: translateX(250%); }
}

/* CS50x progress log (content/*/projects/cs50x.md): a live progress bar and
   reflections list, fetched at build time from a separate public repo
   (content/_data/cs50x.js) -- see that repo's own README for why it holds no
   solution code, just completion status and personal reflections.
   The list reads as a syllabus timeline (a spine + small step markers)
   rather than a stack of identical cards -- ten repeated boxes read as a
   generated feed, a connected trail of steps reads as a journal. */
.cs50x-progress {
  margin: 2.5rem 0;
}

.cs50x-progress-bar {
  height: 10px;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  overflow: hidden;
  border-radius: 999px;
}

.cs50x-progress-bar-fill {
  height: 100%;
  background: linear-gradient(90deg,
    #ffab19, #5c9fd6, #2dd4bf, #a78bfa, #fb7185,
    #818cf8, #ffd43b, #34d399, #e34f26, #94a3b8,
    #ffab19);
  background-size: 46rem 100%;
  filter: saturate(65%) brightness(0.85);
  box-shadow: 0 0 6px 0 rgba(167, 139, 250, 0.2);
  transition: width 0.6s var(--ease-snap);
}

@keyframes cs50x-bar-flow {
  to { background-position: -46rem 0; }
}

@media (prefers-reduced-motion: no-preference) {
  .cs50x-progress-bar-fill {
    animation: cs50x-bar-flow 14s linear infinite;
  }
}

.cs50x-progress-label {
  margin: 0.6rem 0 1.5rem;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.85rem;
  letter-spacing: 0.04em;
  color: var(--text-muted);
}

.cs50x-progress-list {
  --topic-color: #d3a875;
  list-style: none;
  padding: 0;
  margin: 0;
}

.cs50x-progress-list li {
  position: relative;
  padding: 0 0 1.75rem 2.25rem;
  color: var(--text-muted);
}

.cs50x-progress-list li:last-child {
  padding-bottom: 0;
}

/* the connecting spine between steps */
.cs50x-progress-list li::after {
  content: "";
  position: absolute;
  left: 0.5rem;
  top: 1.6rem;
  bottom: 0.1rem;
  width: 1px;
  background: var(--border);
}

.cs50x-progress-list li:last-child::after {
  display: none;
}

.cs50x-progress-check {
  position: absolute;
  left: 0;
  top: 0.05rem;
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: "SFMono-Regular", ui-monospace, "Cascadia Code", "JetBrains Mono", Consolas, monospace;
  font-size: 0.6rem;
  color: var(--text-muted);
}

.cs50x-progress-list li.is-complete .cs50x-progress-check {
  background: color-mix(in srgb, var(--topic-color) 35%, var(--bg-elevated));
  border-color: color-mix(in srgb, var(--topic-color) 60%, var(--border));
  color: #34d399;
}

.cs50x-progress-title {
  font-weight: 600;
  color: var(--text-muted);
}

.cs50x-progress-list li.is-complete .cs50x-progress-title {
  color: var(--text);
}

.cs50x-progress-reflection {
  margin: 0.35rem 0 0;
  font-size: 0.9rem;
  color: var(--text-muted);
}
