:root {
  --bg: #121214;
  --surface: #19191b;
  --text-primary: #f2f1ed;
  --text-secondary: #8c8a82;
  --border: #2a2a2d;
  --violet: #8b7ff0;
  --acid: #c8ff5c;
  --font-body: 'Inter', system-ui, sans-serif;
  --font-display: 'Space Grotesk', sans-serif;

  /* motion tokens: the strong ease-out this project already used by hand in
     a dozen places, now a single source of truth instead of a repeated literal */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);

  /* shadows tinted toward the brand's cool-violet undertone instead of flat black */
  --shadow-sm: 0 10px 24px -12px rgba(9, 7, 18, 0.6);
  --shadow-lg: 0 40px 70px -30px rgba(9, 7, 18, 0.7);
  --shadow-xl: 0 55px 100px -30px rgba(9, 7, 18, 0.82);
}

/* mobile Chrome/Safari flash a translucent color over whatever you tap
   (buttons, links) by default, meant as touch feedback for plain unstyled
   controls; every clickable thing here already has its own real :active/
   :hover feedback (scale, color, background), so this just doubled up as
   an unwanted blue flash on top of it. */
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }

html, body {
  height: 100%;
  overflow: hidden;
}

body {
  background: var(--bg);
  color: var(--text-primary);
  font-family: var(--font-body);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; text-decoration: none; }
button { font-family: inherit; }

/* a slightly-too-long press on a nav item or icon otherwise triggers the
   OS's own text-selection highlight (the same blue as selecting a
   paragraph) even though there's no real text there to select */
button, a, [role="button"], .menu-item, .cf-arrow, .cf-expand, .cf-play-icon,
.archive-title-item, .archive-title-arrow, .lightbox-close, .lightbox-arrow,
.menu-mobile-arrow, .menu-expand-toggle, .menu-expand-close, .coverflow-dots button {
  -webkit-user-select: none;
  user-select: none;
}

/* ================= grain ================= */
/* a near-invisible film-grain layer so flat dark surfaces don't read as sterile,
   kept extremely subtle to respect the "restraint" rule on this palette */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.035;
  mix-blend-mode: overlay;
  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");
}

/* ================= accessibility: keyboard focus ================= */

a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--acid);
  outline-offset: 3px;
  border-radius: 6px;
}

/* ================= page entrance ================= */

.page-loader {
  position: fixed;
  inset: 0;
  z-index: 3000;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  /* the bar-fill below finishes at 0.35s + 1.6s = 1.95s; this delay must
     start strictly after that (with a short hold) or the exit fade begins
     while the bar is still visibly short of 100%, reading as a loader that
     never got to finish */
  animation: loader-exit 0.9s cubic-bezier(0.76, 0, 0.24, 1) forwards;
  animation-delay: 2.25s;
}

/* belt-and-suspenders: guarantee nothing underneath is reachable until the
   loader has fully finished, regardless of any stacking edge case */
body.is-loading .app-shell {
  pointer-events: none;
}

.page-loader-name {
  font-family: var(--font-display);
  font-size: clamp(26px, 4vw, 42px);
  font-weight: 500;
  color: var(--text-primary);
  opacity: 0;
  transform: scale(0.92);
  animation: loader-name-in 1s var(--ease-out) forwards;
  animation-delay: 0.2s;
}

.page-loader-bar {
  position: absolute;
  top: 62%;
  left: 50%;
  transform: translateX(-50%);
  width: 120px;
  height: 2px;
  background: var(--border);
  overflow: hidden;
  border-radius: 2px;
}

.page-loader-bar::after {
  content: '';
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 0%;
  background: linear-gradient(90deg, var(--violet), var(--acid));
  animation: loader-bar-fill 1.6s cubic-bezier(0.65, 0, 0.35, 1) forwards;
  animation-delay: 0.35s;
}

@keyframes loader-name-in { to { opacity: 1; transform: scale(1); } }
@keyframes loader-bar-fill { to { width: 100%; } }
@keyframes loader-exit { to { opacity: 0; transform: scale(1.04); } }

@media (prefers-reduced-motion: reduce) {
  .page-loader { animation-duration: 0.3s; animation-delay: 0.2s; }
  .page-loader-name { animation-duration: 0.1s; animation-delay: 0s; }
  .page-loader-bar::after { animation-duration: 0.1s; animation-delay: 0s; }
}

/* ================= magnetic hover ================= */

[data-magnetic] { transition: transform 0.25s var(--ease-out); }

/* ================= app shell ================= */

.app-shell {
  display: flex;
  height: 100vh;
  /* dvh accounts for mobile browser chrome (address bar, bottom toolbar)
     that plain vh doesn't: vh is defined against the viewport's largest
     possible extent, so on a phone where that chrome is currently visible,
     100vh is taller than what's actually on screen and the bottom of the
     page ends up rendered underneath it. Browsers that don't know `dvh`
     simply ignore this line and keep the vh value above. */
  height: 100dvh;
  width: 100vw;
}

/* ---- side menu (looping reel) ---- */

.side-menu {
  flex: none;
  width: 46px;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  border-right: 1px solid var(--border);
  border-radius: 0 28px 28px 0;
  transition: width 0.5s var(--ease-out), border-radius 0.5s var(--ease-out);
}

/* desktop-only: this expands the collapsed 46px rail into the full 300px
   sidebar. Below 860px the mobile block further down turns .side-menu into
   a full-width top bar instead — without this guard, .is-open's 2-class
   specificity beat that mobile rule's 1-class one regardless of screen
   size, so the bar collapsed down to a stray 300px-wide box (flush left,
   the rest of the top strip empty) any time .is-open was true on mobile —
   which is the default state while parked on the hero section. */
@media (min-width: 861px) {
  .side-menu.is-open {
    border-radius: 0 40px 40px 0;
    width: 300px;
  }
}

.menu-collapsed-indicator {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 1;
  transition: opacity 0.5s var(--ease-out);
  pointer-events: none;
}

.side-menu.is-open .menu-collapsed-indicator {
  opacity: 0;
}

/* collapsed-state position rail: a compact stack of ticks standing in for
   the 8 sections; the current one glows and grows, so the collapsed 46px
   strip carries real wayfinding information instead of a static gradient.
   It's the sole visual in the collapsed rail, so .menu-collapsed-indicator's
   own flex centering places it, no absolute positioning needed. */
.menu-tick-rail {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 9px;
  pointer-events: none;
}

.menu-tick {
  width: 2px;
  height: 9px;
  border-radius: 2px;
  background: var(--border);
  transition: height 0.4s var(--ease-out), background 0.4s var(--ease-out), box-shadow 0.4s var(--ease-out);
}

.menu-tick.is-current {
  height: 20px;
  background: var(--acid);
  box-shadow: 0 0 9px rgba(200, 255, 92, 0.65);
  animation: tick-glow 2.6s ease-in-out infinite;
}

@keyframes tick-glow {
  0%, 100% { box-shadow: 0 0 9px rgba(200, 255, 92, 0.65); }
  50% { box-shadow: 0 0 14px rgba(200, 255, 92, 0.95); }
}

@media (prefers-reduced-motion: reduce) {
  .menu-tick.is-current { animation: none; }
}

@media (max-width: 860px) {
  .menu-tick-rail { display: none; }
}

.side-menu-brand {
  position: absolute;
  top: 32px;
  left: 32px;
  font-family: var(--font-display);
  font-size: 14px;
  font-weight: 500;
  opacity: 0;
  transition: opacity 0.5s var(--ease-out);
  white-space: nowrap;
}

.side-menu.is-open .side-menu-brand {
  opacity: 1;
}

.side-menu-brand span {
  display: block;
  font-family: var(--font-body);
  font-size: 11px;
  color: var(--text-secondary);
  margin-top: 2px;
}

.menu-viewport {
  position: relative;
  height: 380px;
  width: 100%;
  overflow: hidden;
  opacity: 0;
  transition: opacity 0.5s var(--ease-out);
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 30%, black 70%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, black 30%, black 70%, transparent 100%);
}

.side-menu.is-open .menu-viewport {
  opacity: 1;
}

.menu-reel {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  will-change: transform;
  transition: transform 0.55s var(--ease-out);
}

.menu-item {
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  border: none;
  background: none;
  cursor: pointer;
  font-family: var(--font-display);
  font-size: 16px;
  color: var(--text-secondary);
  opacity: 0.32;
  transform: scale(0.82);
  transition: opacity 0.4s var(--ease-out),
              transform 0.4s var(--ease-out),
              color 0.3s ease;
  text-align: center;
  white-space: nowrap;
}

.menu-item:hover { color: var(--violet); }
.menu-item:hover:not(.is-active) { transform: scale(0.92); }

.menu-item.is-active {
  opacity: 1;
  color: var(--text-primary);
  transform: scale(1.18);
}

.menu-item.is-bounce { animation: menu-bounce 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); }

@keyframes menu-bounce {
  0% { transform: scale(1.18); }
  40% { transform: scale(1.32); }
  100% { transform: scale(1.18); }
}

.menu-center-marks {
  position: absolute;
  left: 14px;
  right: 14px;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  justify-content: space-between;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  transition: opacity 0.5s var(--ease-out);
}

.side-menu.is-open .menu-center-marks {
  opacity: 1;
}

.menu-center-marks span {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--acid);
  box-shadow: 0 0 8px rgba(200, 255, 92, 0.7);
}

.menu-hint {
  position: absolute;
  bottom: 32px;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 11px;
  color: var(--text-secondary);
  letter-spacing: 0.05em;
  opacity: 0;
  transition: opacity 0.5s var(--ease-out);
}

.side-menu.is-open .menu-hint {
  opacity: 1;
}

.menu-mobile-arrow { display: none; }
.menu-expand-toggle, .menu-expand-close { display: none; }

@media (max-width: 860px) {
  /* the base desktop rule only rounds the right edge (it's a left-docked
     vertical bar there); as a full-width top bar on mobile that left the
     right corners rounded and the left corners square instead of matching */
  .side-menu { width: 100%; height: 96px; border-right: none; border-bottom: 1px solid var(--border); border-radius: 0; }
  .app-shell { flex-direction: column; }
  .menu-collapsed-indicator { display: none; }
  .menu-viewport {
    height: 96px;
    width: 100%;
    opacity: 1;
    /* narrower fade than the desktop mask: at 220px-wide items on a phone
       screen, the old 20%/80% fade zone covered almost the entire sliver of
       the neighboring item that peeked in on each side, so by the time any
       of it was visible it had already faded to nothing, and nothing on
       screen suggested there was more to scroll to. This keeps most of
       that peek fully legible. */
    -webkit-mask-image: linear-gradient(to right, transparent 0%, black 4%, black 96%, transparent 100%);
    mask-image: linear-gradient(to right, transparent 0%, black 4%, black 96%, transparent 100%);
  }
  .menu-reel { top: 0; left: 50%; display: flex; flex-direction: row; }
  .menu-item {
    height: 96px;
    width: 220px;
    flex: none;
    /* brighter than the desktop resting state (0.32) for the same reason as
       the mask above: a barely-visible neighbor doesn't read as "swipe for
       more", it just reads as empty space. Desktop's 0.32 assumes a mouse
       user hovering close to a monitor; on a phone screen (brighter
       ambient light, more likely viewed at an angle) that same value read
       as too pale to make out at all, so this needs to run brighter than
       the desktop baseline even accounting for the "there's more" bump. */
    opacity: 0.72;
  }
  .menu-center-marks { left: 50%; right: auto; top: 14px; bottom: 14px; flex-direction: column; transform: translateX(-50%); opacity: 1; }
  .side-menu-brand { display: none; }
  .menu-hint { display: none; }

  /* decorative only now: the real tap target is the whole side of the bar
     (see the sideMenu click listener in script.js), not this small circle,
     so this doesn't intercept clicks itself */
  .menu-mobile-arrow {
    display: flex;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 30px;
    height: 30px;
    border-radius: 50%;
    border: 1.5px solid rgba(242, 241, 237, 0.3);
    background: rgba(18, 18, 20, 0.72);
    color: var(--text-primary);
    align-items: center;
    justify-content: center;
    font-size: 15px;
    z-index: 5;
    box-shadow: var(--shadow-sm);
    pointer-events: none;
  }
  .menu-mobile-arrow.prev { left: 6px; }
  .menu-mobile-arrow.next { right: 6px; }

  /* a small tab hanging off the bottom edge of the bar; opens the exact
     same reel fullscreen instead of this cramped horizontal strip (see
     .side-menu.is-expanded below and mobileExpanded in script.js) */
  .menu-expand-toggle {
    display: flex;
    position: absolute;
    left: 50%;
    bottom: -14px;
    transform: translateX(-50%);
    width: 34px;
    height: 20px;
    border-radius: 0 0 12px 12px;
    border: 1.5px solid rgba(242, 241, 237, 0.3);
    border-top: none;
    background: rgba(18, 18, 20, 0.85);
    align-items: flex-end;
    justify-content: center;
    padding-bottom: 3px;
    cursor: pointer;
    z-index: 6;
    box-shadow: var(--shadow-sm);
  }
  .menu-expand-toggle span {
    width: 0;
    height: 0;
    border-left: 5px solid transparent;
    border-right: 5px solid transparent;
    border-top: 6px solid var(--text-primary);
  }

  .menu-expand-close {
    display: none;
    position: absolute;
    top: 20px;
    right: 20px;
    width: 36px;
    height: 36px;
    border-radius: 50%;
    border: 1.5px solid rgba(242, 241, 237, 0.3);
    background: rgba(18, 18, 20, 0.72);
    color: var(--text-primary);
    align-items: center;
    justify-content: center;
    cursor: pointer;
    font-size: 16px;
    z-index: 902;
    box-shadow: var(--shadow-sm);
  }

  /* the fullscreen picker: same reel, same physics, just re-oriented back
     to vertical (like desktop) and blown up to cover the page instead of
     being squeezed into a 96px strip. Scrolling/dragging through it and
     tapping a word is the same interaction as the desktop sidebar, which
     is exactly the point, this is that same menu, not a different one. */
  .side-menu.is-expanded {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    z-index: 900;
    border-radius: 0;
    border-bottom: none;
    background: var(--bg);
  }
  .side-menu.is-expanded .menu-mobile-arrow,
  .side-menu.is-expanded .menu-expand-toggle {
    display: none;
  }
  .side-menu.is-expanded .menu-expand-close {
    display: flex;
  }
  .side-menu.is-expanded .menu-viewport {
    height: 100%;
    -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 30%, black 70%, transparent 100%);
    mask-image: linear-gradient(to bottom, transparent 0%, black 30%, black 70%, transparent 100%);
  }
  .side-menu.is-expanded .menu-reel {
    top: 50%;
    left: 0;
    flex-direction: column;
  }
  .side-menu.is-expanded .menu-item {
    height: 64px;
    width: 100%;
    font-size: 22px;
    /* same reasoning as the compact bar's opacity above: desktop's 0.32
       resting state reads as too pale on a phone screen */
    opacity: 0.65;
  }
  .side-menu.is-expanded .menu-center-marks {
    left: 20px;
    right: 20px;
    top: 50%;
    bottom: auto;
    flex-direction: row;
    transform: translateY(-50%);
  }
}

@media (prefers-reduced-motion: reduce) {
  .menu-expand-toggle, .menu-expand-close { transition-duration: 0.01ms !important; }
}

/* ---- content panel ---- */

.content-panel {
  position: relative;
  flex: 1;
  height: 100vh;
  height: 100dvh; /* see .app-shell for why */
  overflow: hidden;
}

.panel-section {
  position: absolute;
  inset: 0;
  padding: 60px 48px 32px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  opacity: 0;
  transform: translateY(40px);
  filter: blur(6px);
  pointer-events: none;
  transition: opacity 0.5s var(--ease-out), transform 0.5s var(--ease-out), filter 0.5s var(--ease-out);
}

.panel-section.offscreen-down { transform: translateY(40px); }
.panel-section.offscreen-up { transform: translateY(-40px); }

.panel-section.is-active {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
  pointer-events: auto;
  z-index: 2;
}

@media (prefers-reduced-motion: reduce) {
  .panel-section { filter: none; }
}

/* staggered reveal: direct children, then the two grid columns, then the
   first column's own children (eyebrow -> heading -> meta -> text) */
.panel-section > *,
.project-grid > *,
.about-grid > *,
.contact-grid > *,
.project-grid > div:first-child > *,
.about-grid > div:first-child > *,
.contact-main > * {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.5s var(--ease-out), transform 0.5s var(--ease-out);
}

.panel-section.is-active > *,
.panel-section.is-active .project-grid > *,
.panel-section.is-active .about-grid > *,
.panel-section.is-active .contact-grid > *,
.panel-section.is-active .project-grid > div:first-child > *,
.panel-section.is-active .about-grid > div:first-child > *,
.panel-section.is-active .contact-main > * {
  opacity: 1;
  transform: translateY(0);
}

.panel-section.is-active > *:nth-child(1) { transition-delay: 0.05s; }
.panel-section.is-active > *:nth-child(2) { transition-delay: 0.13s; }
.panel-section.is-active > *:nth-child(3) { transition-delay: 0.21s; }

.panel-section.is-active .project-grid > *:nth-child(1),
.panel-section.is-active .about-grid > *:nth-child(1),
.panel-section.is-active .contact-grid > *:nth-child(1) { transition-delay: 0.1s; }
.panel-section.is-active .project-grid > *:nth-child(2),
.panel-section.is-active .about-grid > *:nth-child(2),
.panel-section.is-active .contact-grid > *:nth-child(2) { transition-delay: 0.3s; }

.panel-section.is-active .project-grid > div:first-child > *:nth-child(1),
.panel-section.is-active .about-grid > div:first-child > *:nth-child(1),
.panel-section.is-active .contact-main > *:nth-child(1) { transition-delay: 0.12s; }
.panel-section.is-active .project-grid > div:first-child > *:nth-child(2),
.panel-section.is-active .about-grid > div:first-child > *:nth-child(2),
.panel-section.is-active .contact-main > *:nth-child(2) { transition-delay: 0.18s; }
.panel-section.is-active .project-grid > div:first-child > *:nth-child(3),
.panel-section.is-active .about-grid > div:first-child > *:nth-child(3),
.panel-section.is-active .contact-main > *:nth-child(3) { transition-delay: 0.24s; }
.panel-section.is-active .project-grid > div:first-child > *:nth-child(4),
.panel-section.is-active .about-grid > div:first-child > *:nth-child(4) { transition-delay: 0.3s; }

@media (prefers-reduced-motion: reduce) {
  .panel-section, .panel-section > *, .project-grid > *, .about-grid > *, .contact-grid > *,
  .project-grid > div:first-child > *, .about-grid > div:first-child > *, .contact-main > * {
    transition-delay: 0s !important;
  }
}

@media (max-width: 860px) {
  /* env(safe-area-inset-bottom) is 0 unless the viewport meta tag has
     viewport-fit=cover (set on <html> in index.html); with it, this keeps
     content clear of a phone's own gesture bar / home indicator, which can
     otherwise sit right on top of whatever's at the very bottom of the page */
  .panel-section { padding: 32px 24px calc(48px + env(safe-area-inset-bottom)); }
}

/* ---- hero panel ---- */

#panel-hero { padding: 0; overflow: hidden; }

.hero-panel-media {
  position: absolute;
  inset: 0;
}

.hero-panel-media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.hero-panel-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(18,18,20,0.2) 0%, rgba(18,18,20,0.55) 70%, rgba(18,18,20,0.85) 100%);
}

.hero-panel-content {
  position: relative;
  z-index: 2;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 64px;
}

.hero-panel-content .eyebrow {
  font-size: 13px;
  color: var(--text-secondary);
  margin-bottom: 18px;
}

.hero-panel-content h1 {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(32px, 5.4vw, 64px);
  line-height: 1.08;
  letter-spacing: -0.02em;
  max-width: 820px;
  text-wrap: balance;
}

@media (max-width: 860px) {
  /* the desktop hero is text overlaid on a full-bleed background video,
     which only reads well when the panel is wide enough for the scrim
     gradient to keep the text legible over whatever's playing behind it.
     On a narrow phone that overlay was fighting the text for the same
     cramped space; stacking the video as its own block above the text
     (normal flex flow, not the desktop's absolutely-positioned full-bleed
     layer) gives each its own room, and the text is never in danger of
     rendering on top of a busy part of the frame. */
  .hero-panel-media {
    position: relative;
    inset: auto;
    height: 42vh;
    flex: none;
  }
  .hero-panel-scrim { display: none; }
  .hero-panel-content {
    position: relative;
    height: auto;
    flex: 1;
    min-height: 0;
    justify-content: center;
    padding: 24px 28px calc(32px + env(safe-area-inset-bottom));
  }
  .hero-panel-content .eyebrow { margin-bottom: 12px; }
  .hero-panel-content h1 { font-size: clamp(26px, 7.5vw, 38px); line-height: 1.18; }
  /* safety net, not the plan: the stacked blocks above are sized to fit
     without it, but a real (not lorem-ipsum) heading longer than expected
     should scroll into view rather than silently clip */
  #panel-hero { overflow-y: auto; }
}

/* ---- shared panel typography ---- */

.panel-heading {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(24px, 3.2vw, 38px);
  line-height: 1.12;
  letter-spacing: -0.015em;
  margin-bottom: 6px;
  text-wrap: balance;
}

.panel-subtitle {
  font-size: 14px;
  color: var(--text-secondary);
  margin-bottom: 18px;
  max-width: 46ch;
}

.panel-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
  letter-spacing: 0.06em;
  margin-bottom: 10px;
}

.panel-eyebrow .dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--violet);
  box-shadow: 0 0 10px rgba(139, 127, 240, 0.6);
  animation: dot-pulse 2.6s ease-in-out infinite;
}

@keyframes dot-pulse {
  0%, 100% { box-shadow: 0 0 10px rgba(139, 127, 240, 0.6); }
  50% { box-shadow: 0 0 16px rgba(139, 127, 240, 0.95); }
}

.meta-tag {
  font-size: 11px;
  letter-spacing: 0.015em;
  color: var(--text-secondary);
  border: 1px solid var(--border);
  border-radius: 20px;
  padding: 4px 10px;
  transition: border-color 0.25s var(--ease-out), color 0.25s var(--ease-out), transform 0.25s var(--ease-out);
  display: inline-block;
}
.meta-tag:hover { border-color: rgba(139, 127, 240, 0.6); color: var(--violet); transform: translateY(-1px); }

.panel-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 20px;
}

/* individual pills cascade in rather than the whole row appearing at once */
.panel-section.is-active .panel-meta .meta-tag,
.panel-section.is-active .about-skills .meta-tag {
  transition: opacity 0.4s var(--ease-out), transform 0.4s var(--ease-out);
}
.panel-meta .meta-tag, .about-skills .meta-tag {
  opacity: 0;
  transform: translateY(8px);
}
.panel-section.is-active .panel-meta .meta-tag,
.panel-section.is-active .about-skills .meta-tag {
  opacity: 1;
  transform: translateY(0);
}
.panel-meta .meta-tag:nth-child(1), .about-skills .meta-tag:nth-child(1) { transition-delay: 0.28s; }
.panel-meta .meta-tag:nth-child(2), .about-skills .meta-tag:nth-child(2) { transition-delay: 0.32s; }
.panel-meta .meta-tag:nth-child(3), .about-skills .meta-tag:nth-child(3) { transition-delay: 0.36s; }
.panel-meta .meta-tag:nth-child(4), .about-skills .meta-tag:nth-child(4) { transition-delay: 0.4s; }
.panel-meta .meta-tag:nth-child(5), .about-skills .meta-tag:nth-child(5) { transition-delay: 0.44s; }
.panel-meta .meta-tag:nth-child(6), .about-skills .meta-tag:nth-child(6) { transition-delay: 0.48s; }
.panel-meta .meta-tag:nth-child(7), .about-skills .meta-tag:nth-child(7) { transition-delay: 0.52s; }

@media (prefers-reduced-motion: reduce) {
  .panel-meta .meta-tag, .about-skills .meta-tag { transition-delay: 0s !important; }
}

.panel-text p {
  font-size: 14px;
  line-height: 1.6;
  margin-bottom: 12px;
  max-width: 52ch;
  text-wrap: pretty;
}

.panel-label {
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--violet);
  margin: 16px 0 4px;
}

/* ---- about panel layout ---- */

.about-grid {
  display: grid;
  /* same ratio as .project-grid so the about coverflow renders at the same
     size and alignment as the project galleries, not a narrower one.
     Percentages, not fr units: fr-unit grid-template-columns transitions
     aren't reliably monotonic across engines (a known Grid animation
     limitation), which is what was producing the little reverse-direction
     hitch on both open and close. Percentages are a plain interpolatable
     value with no such quirk. gap is now listed in the transition too,
     instead of snapping instantly at the start of the animation.
     Unlike fr, percentage tracks don't automatically subtract the gap from
     themselves: 42.5% + 57.5% + 56px gap is 56px wider than the container,
     which is exactly what was forcing the horizontal scrollbar while the
     menu was closed. calc() bakes half the gap out of each column instead,
     and since gap animates on the same 0.5s curve, this stays exactly
     correct at every point in the transition, not just at the two ends. */
  grid-template-columns: calc(42.5% - 28px) calc(57.5% - 28px);
  gap: 56px;
  /* stretch (not center) so the text column's height always matches the row,
     that's what lets its overflow:hidden actually clip anything. With center,
     the row sizes to content instead, so while grid-template-columns is
     animating narrower (menu opening) the text reflows into many more lines
     with nothing capping how tall that's allowed to get: it was briefly
     overflowing the panel and popping a scrollbar mid-transition, which is
     its own extra, unplanned movement. .coverflow opts back out of the
     stretch with its own align-self so the gallery still centers. */
  align-items: stretch;
  flex: 1;
  min-height: 0;
  transition: grid-template-columns 0.5s var(--ease-out), gap 0.5s var(--ease-out);
}

/* desktop-only: below 1100px .about-grid is already a single 1fr column
   (see its own mobile query) — this rule's 3-class specificity beat that
   1-class one regardless of screen width, so on mobile it was forcing a
   second, near-zero-width column back into existence instead of leaving
   the stacked single-column layout alone. That stray 0%-wide column is
   exactly where the panel's own text was landing, clipped to invisible. */
@media (min-width: 861px) {
  .app-shell.menu-open .about-grid {
    grid-template-columns: 0% 100%;
    gap: 0;
  }
}

.about-grid > div:first-child { overflow: hidden; min-height: 0; }

.about-skills {
  margin-top: 18px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

@media (max-width: 1100px) {
  /* the 56px gap makes sense as a column gap on desktop; stacked into rows
     here, that much space pushed the gallery so far below the text that
     it barely peeked into view, giving no hint there was anything to
     scroll down for */
  .about-grid { grid-template-columns: 1fr; height: auto; flex: none; gap: 20px; }
  /* gallery first, text after: matches the archive (media above its
     description already) and the restacked hero (video above its text),
     and puts the more visually inviting part of the panel where it's
     actually seen first on a phone instead of buried under a wall of text */
  .about-grid > div:first-child { order: 2; }
  .about-grid > .coverflow { order: 1; }
  /* the stagger-reveal delays below are keyed to DOM position (nth-child),
     not the visual `order` above, so without this the text (now seen
     second) would still fade in on the earlier timer meant for whichever
     panel half appears first, arriving before the gallery it now sits
     under */
  .panel-section.is-active .about-grid > div:first-child { transition-delay: 0.3s; }
  .panel-section.is-active .about-grid > .coverflow { transition-delay: 0.1s; }
}

/* ---- project panel layout ---- */

.project-grid {
  display: grid;
  /* percentages with the gap baked out via calc(): see .about-grid for why */
  grid-template-columns: calc(42.5% - 28px) calc(57.5% - 28px);
  gap: 56px;
  flex: 1;
  min-height: 0;
  /* see .about-grid for why this is stretch, not center */
  align-items: stretch;
  transition: grid-template-columns 0.5s var(--ease-out), gap 0.5s var(--ease-out);
}

/* desktop-only: same reasoning as .about-grid above */
@media (min-width: 861px) {
  .app-shell.menu-open .project-grid {
    grid-template-columns: 0% 100%;
    gap: 0;
  }
}

.project-grid > div:first-child { overflow: hidden; min-height: 0; }

@media (max-width: 1100px) {
  /* see .about-grid for why this is tightened from the desktop 56px */
  .project-grid { grid-template-columns: 1fr; height: auto; flex: none; gap: 20px; }
  /* see .about-grid for why the gallery goes first here too */
  .project-grid > div:first-child { order: 2; }
  .project-grid > .coverflow { order: 1; }
  .panel-section.is-active .project-grid > div:first-child { transition-delay: 0.3s; }
  .panel-section.is-active .project-grid > .coverflow { transition-delay: 0.1s; }
}

/* ================= vertical coverflow (project galleries + about photos) ================= */

/* only the project/about galleries sit in a CSS Grid column (align-items:
   stretch, kept that way so their text column can clip overflow); .coverflow
   opts back out there to center itself vertically in the row instead of
   being pinned to the top. Scoped to just those two contexts: a bare
   .coverflow { align-self: center } used to apply everywhere the class was
   used, including inside the archive's flex layout, where align-self also
   controls horizontal stretch, and it was overriding the parent's
   align-items: stretch there and collapsing the whole gallery to 0 width. */
.project-grid .coverflow,
.about-grid .coverflow {
  align-self: center;
}

.coverflow {
  position: relative;
  height: clamp(420px, 56vh, 560px);
  margin-top: 0;
  transition: height 0.5s var(--ease-out), margin-top 0.5s var(--ease-out);
  perspective: 1600px;
  overflow: hidden;
}

/* the edge fade lives on the stage, not on .coverflow itself, so it only
   touches the sliding photos. The arrows and dots sit near those same
   left/right edges (left: 4px, right: 4px); putting the mask on their
   shared parent used to fade them out too, which is why they looked dim
   and "smudged" instead of crisp buttons. */
.coverflow-stage {
  position: relative;
  width: 100%;
  height: 100%;
  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 9%, black 91%, transparent 100%);
  mask-image: linear-gradient(to right, transparent 0%, black 9%, black 91%, transparent 100%);
}

.cf-item {
  position: absolute;
  top: 50%;
  left: 50%;
  width: min(560px, 84%);
  aspect-ratio: 4 / 3;
  cursor: pointer;
  transition: transform 0.55s var(--ease-out),
              opacity 0.55s var(--ease-out);
  will-change: transform, opacity;
}

.cf-item-inner {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 24px;
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  border: 1px solid var(--border);
  /* the "bigger in focus mode" grow used to be a width: X% change on
     .cf-item, resolved against .coverflow's own width, which is ALSO
     animating at the same time when the menu opens/closes. Two nested
     percentage resolutions animating against each other produced an
     unstable in-between size each frame, which is what looked like the
     card briefly darting left before settling right. A transform: scale on
     this inner layer instead grows the card from its own fixed center,
     completely independent of whatever the container is doing. */
  transition: transform 0.5s var(--ease-out);
}

/* desktop-only "focus mode" grow, see the big comment further down where
   the rest of focus mode lives — kept out of mobile for the same reason:
   nothing there should react to .menu-open at all */
@media (min-width: 861px) {
  .app-shell.menu-open .cf-item-inner {
    transform: scale(1.08);
  }
}

.cf-item.is-center { cursor: default; }
.cf-item.is-center .cf-item-inner { box-shadow: var(--shadow-xl); }

.cf-media {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: #0c0c0d;
  display: block;
}

/* the lightbox is the site's only "fullscreen": the native player's own
   fullscreen button (part of the real <video controls> once a video is
   playing) would open a second, competing fullscreen that hides our own
   close/prev/next chrome. controlslist="nofullscreen" on the element
   already blocks it; this is the Chromium-specific belt-and-suspenders
   for browsers that render the button anyway. */
.cf-media::-webkit-media-controls-fullscreen-button,
.lightbox-content video::-webkit-media-controls-fullscreen-button {
  display: none !important;
}

.cf-play-icon {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 56px;
  height: 56px;
  border-radius: 50%;
  border: none;
  background: rgba(18, 18, 20, 0.55);
  backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 4;
  transition: transform 120ms var(--ease-out);
}

.cf-play-icon::before {
  content: '';
  border-style: solid;
  border-width: 9px 0 9px 15px;
  border-color: transparent transparent transparent var(--text-primary);
  margin-left: 4px;
}

/* used to be invisible until hover (opacity: 0, pointer-events: none),
   with a touch-device carve-out just to keep it reachable at all. That
   made it too easy to miss entirely, on any device, matching the same
   "make it a real visible button" treatment the coverflow arrows already
   got: a real border/fill/shadow instead of a bare translucent glyph. */
.cf-expand {
  position: absolute;
  top: 14px;
  right: 14px;
  width: 34px;
  height: 34px;
  border-radius: 9px;
  border: 1.5px solid rgba(242, 241, 237, 0.3);
  background: rgba(18, 18, 20, 0.72);
  color: var(--text-primary);
  backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 6;
  opacity: 0.85;
  box-shadow: var(--shadow-sm);
  transition: opacity 0.25s var(--ease-out), background 0.25s var(--ease-out), border-color 0.25s var(--ease-out), transform 120ms var(--ease-out);
}

/* was a Unicode glyph (⤢); however it was nudged, its exact position still
   depended on font/glyph metrics that differ across browsers and platforms,
   so it never centered reliably everywhere. A hand-drawn SVG has no such
   ambiguity: its own viewBox is what gets centered, not a font's idea of
   where a line of text sits. */
.cf-expand-icon {
  width: 15px;
  height: 15px;
  display: block;
}

.cf-expand:hover { opacity: 1; background: rgba(139, 127, 240, 0.6); border-color: var(--violet); }
.cf-expand:active { transform: scale(0.9); }
/* .cf-play-icon centers itself with translate(-50%,-50%) (see its own rule
   above): a plain `transform: scale()` on :active would replace that
   translate entirely instead of adding to it, snapping the button away from
   center the moment it's pressed. Keeping both parts here is what fixes it. */
.cf-play-icon:active { transform: translate(-50%, -50%) scale(0.9); }

.coverflow-dots {
  position: absolute;
  left: 50%;
  bottom: 4px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: row;
  gap: 7px;
  z-index: 5;
}

.coverflow-dots button {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  border: none;
  padding: 0;
  background: rgba(242, 241, 237, 0.3);
  cursor: pointer;
  transition: background 0.25s ease, transform 0.25s ease;
}

.coverflow-dots button.active { background: var(--acid); transform: scale(1.4); }

.cf-arrow {
  position: absolute;
  top: 50%;
  margin-top: -21px;
  width: 42px;
  height: 42px;
  border-radius: 50%;
  border: 1.5px solid rgba(242, 241, 237, 0.3);
  background: rgba(18, 18, 20, 0.72);
  backdrop-filter: blur(6px);
  color: var(--text-primary);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 20px;
  z-index: 10;
  box-shadow: var(--shadow-sm);
  transition: border-color 0.3s var(--ease-out), background 0.3s var(--ease-out), color 0.3s var(--ease-out);
}

.cf-arrow:hover {
  border-color: var(--acid);
  background: rgba(200, 255, 92, 0.15);
  color: var(--acid);
}

/* transform on this element is owned by the magnetic-hover JS (data-magnetic):
   press feedback is layered on there instead of here, see initMagnetic() */
.cf-arrow.prev { left: 4px; }
.cf-arrow.next { right: 4px; }

@media (max-width: 1100px) {
  /* the desktop height (clamp against viewport HEIGHT) doesn't track the
     item's own size once the grid stacks to a single, narrower column:
     at a typical phone width the 4:3 card only needs ~200px of height,
     but the box was still reserving up to ~455px (56vh), leaving a huge
     empty band above/below the photo that read as too much space before
     the text below even started. Sizing off viewport WIDTH instead
     tracks the item's own width-driven height much more closely. */
  .coverflow { height: clamp(240px, 62vw, 420px); }
  .cf-arrow { width: 34px; height: 34px; margin-top: -17px; font-size: 15px; }
}

/* ================= archive: title carousel + media coverflow ================= */
/* replaces the old coverflow-of-cards (which nested a second photo-carousel
   inside each card, two arrow pairs stacked on the same spot with no way
   to tell them apart). Two separate carousels now, same idea as each other
   (3 slots always visible: prev / center / next, arrows on the outer
   edges) but in different places: the title carousel up top only changes
   which project is shown; the media below is the exact same .coverflow
   component the flagship project galleries use, same sizing inherited
   automatically, nothing archive-specific to keep in sync by hand. */

.archive-browser {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
  margin-top: 8px;
}

/* the flagship galleries are all consistently shot for the 4:3 box above;
   archive material isn't (checked the actual files: a 0.75 portrait, a
   couple of near-4:3, and two ~2.4:1 panoramas). Forcing all of that into
   the same rigid 4:3 crop-container left tiny slivers of image floating in
   huge black bars, which is what read as "cut off / wrong resolution".
   16:9 is a much better middle ground for this specific mix without giving
   up any of the shared coverflow mechanics (arrows, dots, play, expand). */
.archive-project .cf-item {
  aspect-ratio: 16 / 9;
}

/* .archive-project is a full-width flex column with align-items: stretch
   (needed so .coverflow gets a real width at all, see that rule) — left
   alone, .coverflow was stretching to the entire panel width instead of
   the ~600px column flagship galleries get from their grid layout. The
   arrows sit at .coverflow's own edges, so on that full-width box they
   ended up stranded far out past the photo instead of hugging its corners
   like every other gallery on the site. Capping the width back down here
   restores that. */
.archive-project .coverflow {
  width: min(760px, 92%);
  margin-inline: auto;
}

.archive-title-carousel {
  /* a compact, centered cluster rather than stretched edge-to-edge */
  width: min(820px, 100%);
  margin-inline: auto;
  transition: width 0.5s var(--ease-out);
  display: flex;
  align-items: center;
  gap: 14px;
  border-bottom: 1px solid var(--border);
  padding-bottom: 12px;
  margin-bottom: 4px;
}

.archive-title-stage {
  position: relative;
  flex: 1;
  height: 60px;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%);
  mask-image: linear-gradient(to right, transparent 0%, black 8%, black 92%, transparent 100%);
}

/* same dimmed/bright, scale-on-active language as the section menu's own
   reel and the media coverflow's neighbor slides: this is deliberately
   "the same kind of thing" in three different places on the site. Fixed
   pixel offsets (not percentages) keep the 3 slots evenly spaced no matter
   how long each title's text is, and a capped max-width (+ ellipsis) on
   every state is what actually keeps that promise: a longer title used to
   just keep growing past its slot, overlapping its neighbor when centered
   and making the two sides look unevenly weighted otherwise. Now every
   slot has a fixed footprint regardless of the word inside it. */
.archive-title-item {
  position: absolute;
  top: 50%;
  left: 50%;
  border: none;
  background: none;
  cursor: pointer;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 18px;
  color: var(--text-secondary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 130px;
  text-align: center;
  transition: transform 0.5s var(--ease-out), opacity 0.5s var(--ease-out),
              color 0.3s var(--ease-out), font-size 0.5s var(--ease-out);
}

.archive-title-item:hover { color: var(--text-primary); }

/* the center title gets its own explicit size instead of relying only on
   scale() against the neighbors, so it reads as a real heading rather than
   just "the un-shrunk one" */
.archive-title-item.is-center {
  color: var(--text-primary);
  font-weight: 600;
  font-size: 26px;
  max-width: 220px;
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  z-index: 3;
}

.archive-title-item.is-near-prev {
  transform: translate(-50%, -50%) translateX(-155px) scale(0.85);
  opacity: 0.4;
  z-index: 2;
}

.archive-title-item.is-near-next {
  transform: translate(-50%, -50%) translateX(155px) scale(0.85);
  opacity: 0.4;
  z-index: 2;
}

.archive-title-item.is-far {
  transform: translate(-50%, -50%) scale(0.7);
  opacity: 0;
  pointer-events: none;
  z-index: 1;
}

/* brought up to the same visible-button treatment as .cf-arrow: this one
   used to have no fill at all (background: none) and the dimmed secondary
   text color, making it by far the least visible arrow on the site */
.archive-title-arrow {
  flex: none;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1.5px solid rgba(242, 241, 237, 0.3);
  background: rgba(18, 18, 20, 0.72);
  color: var(--text-primary);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 16px;
  box-shadow: var(--shadow-sm);
  transition: border-color 0.3s var(--ease-out), background 0.3s var(--ease-out),
              color 0.3s var(--ease-out), transform 0.2s var(--ease-out);
}

.archive-title-arrow:hover {
  border-color: var(--acid);
  background: rgba(200, 255, 92, 0.15);
  color: var(--acid);
}
.archive-title-arrow:active { transform: scale(0.9); }

.archive-stage {
  position: relative;
  flex: 1;
  min-height: 460px;
}

.archive-project {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  /* stretch, not center: .coverflow's own content (.cf-item) is entirely
     position:absolute, so it has no intrinsic width of its own to size by;
     under align-items:center (which doesn't stretch cross-axis size) it was
     collapsing to near-zero width and the whole gallery just never rendered.
     Flagship project galleries never hit this because .coverflow sits in a
     CSS Grid column there, which stretches items by default. .coverflow
     centers its own contents internally either way, so stretching this
     doesn't change how anything looks once it's actually sized. */
  align-items: stretch;
  opacity: 0;
  transform: translateX(28px);
  filter: blur(6px);
  pointer-events: none;
  transition: opacity 0.45s var(--ease-out), transform 0.45s var(--ease-out), filter 0.45s var(--ease-out);
}

.archive-project.offscreen-right { transform: translateX(28px); }
.archive-project.offscreen-left { transform: translateX(-28px); }

.archive-project.is-active {
  opacity: 1;
  transform: translateX(0);
  filter: blur(0);
  pointer-events: auto;
  z-index: 2;
}

@media (prefers-reduced-motion: reduce) {
  .archive-project { filter: none; }
}

/* the title now lives in the carousel up top; this is just the description,
   and it's explicitly "part of the carousel" too: it crossfades/slides with
   the rest of .archive-project on every project switch (see that rule),
   and gets its own enter animation when the menu closes (focus-mode rule below) */
.archive-desc {
  text-align: center;
  max-width: 56ch;
  /* .archive-project is now align-items: stretch (see there), so this needs
     its own centering: a stretched flex child otherwise sits flush-left
     once its own max-width caps it narrower than the full row */
  margin-inline: auto;
  margin-top: 20px;
  font-size: 13.5px;
  line-height: 1.55;
  color: var(--text-secondary);
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.5s var(--ease-out), transform 0.5s var(--ease-out);
}

/* focus mode: only the title carousel + media stay visible, both a bit
   bigger (the media grows for free, it's the same .coverflow/.cf-item-inner
   that already scale up under .app-shell.menu-open for every other
   gallery), and the description tucks away: it slides back in with the
   rest of the text family once the menu closes */
/* desktop-only: this whole trio hides the description and enlarges the
   title carousel while the sidebar is expanded, matching the size/spacing
   changes "focus mode" makes everywhere else on desktop. Their selectors
   don't check screen width, and beat the mobile title-carousel sizing
   further down on specificity alone, so whenever .menu-open was true on
   mobile (the default while parked on the hero section) the archive titles
   jumped back to these oversized desktop numbers — exactly the overlap
   this file's own mobile block further down was written to prevent. */
@media (min-width: 861px) {
  .app-shell.menu-open [data-panel="archive"] .archive-desc {
    opacity: 0 !important;
    transform: translateY(14px);
    pointer-events: none;
  }

  .app-shell.menu-open [data-panel="archive"] .archive-title-carousel {
    width: min(920px, 100%);
  }

  .app-shell.menu-open [data-panel="archive"] .archive-title-item.is-center {
    font-size: 32px;
    max-width: 260px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .archive-desc, .archive-title-item { transition-duration: 0.01ms !important; }
}

@media (max-width: 860px) {
  .archive-stage { min-height: 380px; }
  .archive-title-carousel { gap: 8px; padding-bottom: 8px; }
  /* the desktop offsets/widths left too little clearance at phone width:
     is-center could reach 80px from its own center (160px / 2) while
     is-near-next started only 100px away, so a long neighboring title
     could reach back in under the centered one instead of sitting past
     its edge — read as titles "stacking" on top of each other. Smaller
     widths and a bit more offset gives real clearance between all three
     slots at this size. */
  .archive-title-item { max-width: 70px; }
  .archive-title-item.is-center { font-size: 19px; max-width: 120px; }
  .archive-title-item.is-near-prev { transform: translate(-50%, -50%) translateX(-110px) scale(0.75); }
  .archive-title-item.is-near-next { transform: translate(-50%, -50%) translateX(110px) scale(0.75); }
  .archive-desc { margin-top: 10px; }
}

/* ================= lightbox ================= */

.lightbox {
  position: fixed;
  inset: 0;
  background: rgba(10, 10, 11, 0.92);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  opacity: 0;
  pointer-events: none;
}

.lightbox.open { opacity: 1; pointer-events: auto; }

.lightbox-content { max-width: 92vw; max-height: 88vh; }

.lightbox-content img,
.lightbox-content video {
  max-width: 92vw;
  max-height: 88vh;
  display: block;
}

/* a portrait or unusually narrow source video, constrained mainly by
   max-height here, can render narrow enough that the browser's own control
   bar doesn't have room to lay out play/scrubber/time/volume/fullscreen
   without overlapping or dropping some of them, since that native UI isn't
   ours to restyle. Giving the video a floor width (still capped by 92vw so
   it never overflows a small phone screen) keeps it wide enough for that
   bar to render properly regardless of the source's own aspect ratio. */
.lightbox-content video {
  min-width: min(480px, 92vw);
}

.lightbox-close,
.lightbox-arrow {
  position: absolute;
  border: 1.5px solid rgba(242, 241, 237, 0.3);
  background: rgba(18, 18, 20, 0.6);
  backdrop-filter: blur(6px);
  color: var(--text-primary);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  z-index: 1001;
  box-shadow: var(--shadow-sm);
  transition: background 0.2s ease, border-color 0.2s ease, transform 120ms var(--ease-out);
}

.lightbox-close:hover,
.lightbox-arrow:hover { background: rgba(139, 127, 240, 0.5); border-color: var(--violet); }
.lightbox-close:active { transform: scale(0.9); }
.lightbox-arrow:active { transform: translateY(-50%) scale(0.9); }

.lightbox-close { top: 24px; right: 28px; width: 38px; height: 38px; font-size: 18px; }
.lightbox-arrow { top: 50%; transform: translateY(-50%); width: 44px; height: 44px; font-size: 22px; }
.lightbox-arrow.prev { left: 24px; }
.lightbox-arrow.next { right: 24px; }

@media (max-width: 720px) {
  .lightbox-arrow { width: 36px; height: 36px; font-size: 18px; }
  .lightbox-arrow.prev { left: 10px; }
  .lightbox-arrow.next { right: 10px; }
  .lightbox-close { top: 14px; right: 14px; }
}

/* ================= contact panel ================= */

.contact-grid {
  display: grid;
  grid-template-columns: 1.3fr 0.7fr;
  gap: 64px;
  align-items: center;
  flex: 1;
  min-height: 0;
}

.contact-main { max-width: 640px; }

.contact-heading {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(30px, 4.6vw, 52px);
  line-height: 1.1;
  letter-spacing: -0.018em;
  text-wrap: balance;
  margin-bottom: 4px;
}

.contact-email {
  font-family: var(--font-display);
  font-size: clamp(22px, 3.4vw, 34px);
  font-weight: 500;
  display: inline-block;
  margin-top: 22px;
  padding: 8px 6px;
  border-radius: 999px;
  transition: color 0.3s var(--ease-out), background 0.3s var(--ease-out), padding 0.3s var(--ease-out);
}
.contact-email:hover { color: var(--violet); background: rgba(139, 127, 240, 0.1); padding: 8px 22px; }

/* a bordered stack of rows instead of three loose inline text links: gives
   the social links equal visual weight to the email and something to align
   against on the page's right half */
.contact-links {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--border);
}

.contact-link-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 4px;
  border-bottom: 1px solid var(--border);
  font-family: var(--font-display);
  font-size: 16px;
  color: var(--text-secondary);
  transition: color 0.25s var(--ease-out), padding-left 0.25s var(--ease-out);
}

.contact-link-row:hover {
  color: var(--text-primary);
  padding-left: 10px;
}

.contact-link-arrow {
  font-size: 14px;
  color: var(--text-secondary);
  transform: translate(0, 0);
  transition: transform 0.25s var(--ease-out), color 0.25s var(--ease-out);
}

.contact-link-row:hover .contact-link-arrow {
  color: var(--acid);
  transform: translate(3px, -3px);
}

@media (max-width: 860px) {
  .contact-grid { grid-template-columns: 1fr; gap: 32px; }
  .contact-links { margin-top: 8px; }
}

/* ================= "focus mode" while the menu is open ================= */
/* while navigating (menu expanded), only the title + gallery stay visible;
   the descriptive text fades back in once the menu closes. Desktop-only:
   on mobile the sidebar is a slim top bar, not a wide expanding panel, so
   there's no layout reason to hide body text or resize the gallery while
   it's open — and .menu-open defaults to true while parked on the hero
   section, so without this guard every one of these rules (none of them
   originally checked screen width) fired on mobile too, hiding text and
   resizing things a phone visitor never asked to hide or resize. */
@media (min-width: 861px) {
  .app-shell.menu-open .panel-section.is-active .panel-text,
  .app-shell.menu-open .panel-section.is-active .panel-meta,
  .app-shell.menu-open .panel-section.is-active .about-skills {
    opacity: 0 !important;
    pointer-events: none;
  }
}

/* same duration and curve as the layout properties below (grid columns,
   card width/height, title width): one timed movement instead of the text
   fading on its own separate, faster clock while the layout is still
   reshaping around it */
.panel-text, .panel-meta, .about-skills {
  transition: opacity 0.5s var(--ease-out);
}

/* panel header (title/subtitle) recenters when the menu is open, to pair
   visually with the enlarged, centered carousel below it.
   `text-align` itself can't be transitioned by CSS: toggling it directly
   made the text snap to its new alignment instantly while everything else
   (grid columns, card width) was still 0.5s into easing, which is exactly
   the "not smooth" jump reported. Fixed by animating the box's own
   position/width instead, both are ordinary lengths, so they interpolate
   properly, and letting text-align just ride along for the (much less
   noticeable) line-wrap alignment inside an already-moving box. */
.panel-header {
  transition: padding 0.5s var(--ease-out);
}

.panel-heading {
  margin-inline: auto;
  max-width: 100%;
  transition: max-width 0.5s var(--ease-out), opacity 0.5s var(--ease-out);
}

/* the subtitle already has a fixed max-width (46ch) in both states, so
   instead of animating its box size, this shifts it sideways by the exact
   distance needed to land centered: a plain calc() length, not the
   keyword `auto`, so it's reliably animatable everywhere */
.panel-subtitle {
  transition: margin-left 0.5s var(--ease-out);
}

@media (min-width: 861px) {
  .app-shell.menu-open .panel-header {
    text-align: center;
  }

  .app-shell.menu-open .panel-heading {
    max-width: 720px;
  }

  .app-shell.menu-open .panel-subtitle {
    margin-left: calc((100% - 46ch) / 2);
  }

  /* the gallery itself grows a bit further once it has the full width to
     itself (see .cf-item-inner's own scale above), with a bit more headroom
     under the now-centered title so it reads as deliberately placed rather
     than just "the same spot, but bigger". The mask (built for softening
     neighbor slides in the smaller default view) is dropped entirely here
     since there's really only one dominant image at this size. */
  .app-shell.menu-open .coverflow {
    height: clamp(460px, 60vh, 620px);
    margin-top: 20px;
  }

  .app-shell.menu-open .coverflow-stage {
    -webkit-mask-image: none;
    mask-image: none;
  }
}

/* ================= reduced motion: extended coverage ================= */
/* the page-loader and stagger-reveal already respect this; this covers the
   remaining transform-driven motion (coverflow, menu reel) by collapsing
   their transitions to a near-instant fade */
@media (prefers-reduced-motion: reduce) {
  .cf-item, .cf-item-inner, .archive-project,
  .archive-title-item,
  .menu-reel, .menu-item,
  .side-menu, .about-grid, .project-grid {
    transition-duration: 0.01ms !important;
  }
}
