/* html normally has overflow:visible, and per the CSS Overflow spec, a root
   element with overflow:visible propagates its BODY child's own overflow up
   to the viewport instead of leaving it on body — meaning a plain
   `body { overflow-y: auto }` doesn't make body a real scroll container at
   all, it just relabels the viewport's own scroll with body's value, and
   scroll-snap-type set on body then governs nothing a real scroll gesture
   ever touches. Confirmed directly (not assumed): a real scroll left
   document.body.scrollTop at 0 while window.scrollY moved. html:has() stops
   the propagation so body becomes the actual scrolling box its own rules
   below already assume it is. Scoped via :has() rather than a bare `html`
   rule so this never touches any other page's normal document scroll. */
html:has(body.theme-personal) {
  overflow: hidden;
}

/* Personal theme — dark, relaxed, life-and-Instagram-first. */
.theme-personal {
  --bg: #0f1419;
  --surface: #1a2332;
  --text: #e8eef7;
  --muted: #9aa8b8;
  --accent: #6ec1ff;
  --border: #263041;
  /* .section-nav's own left offset (--space-2) + icon width (44px) + one
     unit of breathing room — how far content needs to start clear of the
     floating nav. .social-grid breaks out to full viewport width at every
     size (not just mobile), so this applies unconditionally, not behind a
     media query; confirmed via a real overlap at 1024px desktop width, not
     just assumed from the CSS. */
  --nav-clearance: calc(var(--space-2) + 44px + var(--space-3));

  background: var(--bg);
  color: var(--text);

  /* Full-page scroll-snap: each "story" (hero, Instagram, YouTube, Album)
     is its own full-height section (see .life-section below) that settles
     into view completely rather than resting at some arbitrary partial
     scroll position — per direct feedback that sections were reading as
     one overlapping continuous wall of content instead of distinct areas.
     scroll-snap-type has to live on the actual scrolling box — body, made
     real by the html:has() rule above; without it this height/overflow
     pair looks correct in computed style but never receives real scroll
     input. position:fixed children (.life-hero, .site-header, .section-nav)
     are unaffected — overflow alone doesn't change their containing block,
     confirmed directly after wiring this up, not just assumed. */
  height: 100vh;
  height: 100svh;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  scroll-behavior: smooth;
}

.theme-personal a {
  color: var(--accent);
}

/* Load sequence, per direct request: blank screen, then the Black Hole
   grows in from a dot, then content appears one piece at a time (nav, then
   the heading, then the subhead, then everything else) rather than all at
   once. See life-intro.js for the actual timing/orchestration. Everything
   here is scoped under body.js-intro (added synchronously in personal.html,
   before first paint) so a no-JS visitor never sees any of this — no veil,
   no scale, no staggered opacity, header/hero/content just render at their
   normal state from the start, same as before this feature existed. */
.theme-personal.js-intro .life-intro-veil {
  position: fixed;
  inset: 0;
  z-index: 4;
  background: #000;
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.5s var(--ease);
}

.theme-personal.js-intro .life-intro-veil.is-hidden {
  opacity: 0;
}

/* No CSS transform on .life-hero anymore — the "grows from a dot" effect
   used to be a scale() on this whole wrapper, which visually read as the
   camera zooming toward a tiny, already-complete copy of the final image
   rather than the black hole itself forming. window.blackHoleGrow() (see
   life-intro.js and black-hole-lite.js) now animates the disk's own
   visual radius inside the shader instead — this element just holds the
   canvas at its real, constant size throughout. */
.theme-personal.js-intro .life-hero-inner h1,
.theme-personal.js-intro .life-hero-inner p {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.55s var(--ease), transform 0.55s var(--ease);
}

.theme-personal.js-intro .life-hero-inner h1.is-visible,
.theme-personal.js-intro .life-hero-inner p.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* .site-header needs its own fade-in-from-below, same as h1/p above, but it
   already carries a load-bearing transform of its own (translateX(-50%) for
   horizontal centering, set on the base .site-header rule below) — a plain
   "transform: translateY(...)" here would win on specificity and silently
   replace that centering transform outright, since a later/more-specific
   rule's transform doesn't merge with an earlier one's, it replaces it. Both
   halves are composed into one transform value at each stage instead. */
.theme-personal.js-intro .site-header {
  opacity: 0;
  transform: translateX(-50%) translateY(10px);
  transition: opacity 0.55s var(--ease), transform 0.55s var(--ease);
}

.theme-personal.js-intro .site-header.is-visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* The .life-section chapters (Instagram/YouTube/Album) settle immediately
   rather than joining the timed sequence — see life-intro.js's own
   comment: they're all off-screen for the whole intro, so staging their
   reveal has nothing to be visible against anyway. */
.theme-personal.js-intro .life-section {
  opacity: 0;
  transition: opacity 0.6s var(--ease);
}

.theme-personal.js-intro .life-section.is-visible {
  opacity: 1;
}

/* The side nav's own container (text arrives with it, one step) is
   separate from each icon inside it — per direct request, the three
   icons reveal in their own order (Instagram, then YouTube, then Album)
   rather than all arriving as part of the container's own fade. */
.theme-personal.js-intro .section-nav {
  opacity: 0;
  transition: opacity 0.6s var(--ease);
}

.theme-personal.js-intro .section-nav.is-visible {
  opacity: 1;
}

.theme-personal.js-intro .section-nav-link {
  opacity: 0;
  transition: opacity 0.4s var(--ease);
}

.theme-personal.js-intro .section-nav-link.is-visible {
  opacity: 1;
}

/* Pure-CSS failsafe, independent of life-intro.js entirely: if that script
   never completes for any reason in some visitor's browser (a security
   setting, a content blocker, any JS error), everything below would stay
   stuck at opacity:0 forever, with no way to recover without reloading.
   This forces the veil hidden and every .js-intro-gated element to its
   normal, visible state a few seconds after load regardless of whether
   the JS ever ran or finished. Harmless when JS succeeds normally (it
   always does well under 6s) — the animation's end state just matches
   where .is-visible already put things.
   shortcut: a blunt timed reveal, not a real "JS actually failed" check;
   upgrade to a <script> no-js class removal if a real cause is ever found. */
@media (prefers-reduced-motion: no-preference) {
  @keyframes js-intro-failsafe-reveal {
    to {
      opacity: 1;
    }
  }

  @keyframes js-intro-failsafe-hide {
    to {
      opacity: 0;
    }
  }

  .theme-personal.js-intro .life-hero-inner h1,
  .theme-personal.js-intro .life-hero-inner p,
  .theme-personal.js-intro .site-header,
  .theme-personal.js-intro .life-section,
  .theme-personal.js-intro .section-nav,
  .theme-personal.js-intro .section-nav-link {
    animation: js-intro-failsafe-reveal 0.6s var(--ease) 6s forwards;
  }

  .theme-personal.js-intro .life-intro-veil {
    animation: js-intro-failsafe-hide 0.5s var(--ease) 6s forwards;
  }
}

@media (prefers-reduced-motion: reduce) {
  .theme-personal.js-intro .life-intro-veil {
    display: none;
  }

  .theme-personal.js-intro .life-hero-inner h1,
  .theme-personal.js-intro .life-hero-inner p,
  .theme-personal.js-intro .section-nav,
  .theme-personal.js-intro .section-nav-link,
  .theme-personal.js-intro .life-section {
    opacity: 1;
    transform: none;
  }

  /* Not transform: none — .site-header's own centering transform (below)
     still applies under reduced motion, only the slide-in half is dropped. */
  .theme-personal.js-intro .site-header {
    opacity: 1;
    transform: translateX(-50%);
  }
}

/* Shape/position/glass now live in tokens.css, shared with every other
   themed header. z-index stays overridden here, lower than the shared
   default (90): this page's own .life-intro-veil sits at z-index 4 and
   needs to cover the header during the load sequence — moot once the
   header fades in (opacity-driven, not z-index), but keeps the original,
   already-verified stacking order intact rather than re-deriving it. */
.theme-personal .site-header {
  z-index: 3;
}

/* Auto-hides on scroll-down, returns on scroll-up (assets/js/nav-autohide.js)
   — per direct request for the floating nav. Reuses the same transform
   transition already declared on .theme-personal.js-intro .site-header
   below (0.55s) rather than adding a second, lower-specificity one here —
   js-intro is permanent (added synchronously, never removed), so that
   higher-specificity rule always wins anyway; a duration set on this base
   rule would just be dead code. This selector is .js-intro-qualified for
   the same reason: it makes this rule's specificity (4 classes) match
   .site-header.is-visible's own (also 4), so source order — this rule
   comes later — breaks the tie in hidden's favor for a visitor who has
   both classes at once (finished the intro, then scrolled); without that
   match, .is-visible would silently win regardless of order and the
   header would never actually hide. translateX(-50%) is composed into the
   same transform value for the usual reason: a later rule's transform
   replaces an earlier one's outright, it doesn't merge with it. */
.theme-personal.js-intro .site-header.is-nav-hidden {
  transform: translateX(-50%) translateY(-150%);
}

.theme-personal .site-header .header-inner {
  padding-top: var(--space-1);
  padding-bottom: var(--space-1);
}

.theme-personal .site-header nav a {
  text-decoration: none;
  color: var(--muted);
  font-size: 0.95rem;
  transition: color var(--speed) var(--ease);
}

/* Blue accent on hover/current instead of plain white — the site's own
   --accent, not a new color. Doesn't touch .offset-btn ("The Grind"): its
   own !important color is a fixed cross-theme signal by design (see its
   comment in tokens.css) and isn't meant to follow either page's palette. */
.theme-personal .site-header nav a:hover,
.theme-personal .site-header nav a[aria-current="page"] {
  color: var(--accent);
}

/* tokens.css's shared .site-header nav sets flex-wrap: wrap (useful on
   /resume/, where the nav can hold more items) — on this page it only ever
   holds exactly two, and letting them wrap onto separate lines from each
   other was producing a genuinely broken-looking result at narrow widths:
   confirmed via getBoundingClientRect() at 320px that the header-inner AND
   the nav itself were both wrapping, stacking brand/link/button into three
   separate rows inside a border-radius:999px pill — a 171px-tall "pill"
   reads as a layout bug, not a design. nowrap keeps the link+button as one
   unit; the media query below (audited at 320-1920px) shrinks that unit
   enough to fit next to the brand on one line at every width tested,
   rather than accepting a two-row header at the narrow end. */
.theme-personal .site-header nav {
  flex-wrap: nowrap;
}

@media (max-width: 420px) {
  .theme-personal .site-header .header-inner {
    gap: var(--space-1);
  }

  .theme-personal .site-header nav {
    gap: 0.6rem;
  }

  /* min-height, not just padding: shrinking font-size on its own reduces
     the natural line-height enough to drop both below the 44px touch-target
     floor even with padding unchanged — confirmed directly (43.2px/41.1px)
     before adding this, not assumed. align-items:center on the nav/button
     containers keeps the now-taller-than-content box centered correctly. */
  .theme-personal .site-header nav a {
    font-size: 0.85rem;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  .theme-personal .site-header .offset-btn {
    padding: 0.5rem 0.75rem;
    font-size: 0.8rem;
    gap: 0.3rem;
    min-height: 44px;
  }

  .theme-personal .site-header .offset-btn .btn-arrow {
    width: 13px;
    height: 13px;
  }
}

/* scroll-snap-align: start — without it, this was the one part of the page
   with no snap point of its own, and scroll-snap-type: y mandatory (on
   body, see the .theme-personal rule) can never let the scroll container
   rest anywhere that isn't a valid snap point. Confirmed directly: the
   footer was fully present in the DOM and had real scrollable height
   below Album, but any scroll attempt past Album's own snap point just
   pulled straight back to it — the footer was unreachable, not just easy
   to miss. */
.theme-personal .site-footer {
  position: relative;
  z-index: 1;
  scroll-snap-align: start;
  border-top: 1px solid var(--border);
  color: var(--muted);
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.7);
}

.theme-personal .site-footer a {
  color: var(--muted);
}

/* Hero — WebGPU Black Hole background (assets/js/black-hole-lite.js), fixed
   behind the ENTIRE page rather than a bounded top section: this is the
   page's persistent backdrop, not just a hero banner. main#main gets its
   own stacking context (z-index 1) above it, so the hero text, then the
   Instagram cards, then everything after scrolls in normal flow on top of
   a background that never scrolls away. Sized big on purpose — a real
   full-viewport canvas rendered at native resolution, not a CSS transform
   scaled up from a small box (which would just blur it).
   `height: 100svh` (small viewport height, overriding the 100vh fallback
   for browsers that support it) locks the canvas to a STABLE size on
   mobile: `inset: 0` alone tracks the browser's large/dynamic viewport,
   which genuinely changes as the address bar collapses/expands on scroll
   — a real height change, not a rendering bug — and black-hole-lite.js's
   ResizeObserver reacts to that by rebuilding every render target and
   re-baking the expensive geodesic trace mid-scroll, reading as the whole
   hero visibly growing/distorting. svh pins to the smallest-possible
   viewport (as if the address bar were always shown) so that resize never
   fires from chrome alone. */
.theme-personal .life-hero {
  position: fixed;
  inset: 0;
  height: 100vh;
  height: 100svh;
  z-index: 0;
  overflow: hidden;
  /* Same reasoning as .life-hero-canvas just below: this wrapper is purely
     decorative (already aria-hidden) and nothing needs it as a pointer
     target, but being a fixed, full-viewport element left as a hit-target
     by default, it was next in line to reproduce the identical scroll-stuck
     issue the canvas itself had. */
  pointer-events: none;
}

/* pointer-events: none — the canvas doesn't actually need to receive
   pointer input itself: black-hole-lite.js's hover-follow reads
   window-level pointermove (not the canvas's own), and click-effects.js
   listens on document, not the canvas. Left as a real hit-target (the
   default), this position:fixed, full-viewport element was the reason a
   scroll gesture over most of the empty hero/section background — anywhere
   without text or a card directly under the cursor — genuinely failed to
   scroll the page at all, confirmed directly: the wheel event fired (a
   window-level listener saw it) but body.scrollTop never moved, while the
   identical gesture over a text element a few pixels away worked every
   time. Removing it as a hit-target removes that ambiguity outright rather
   than working around a specific browser's resolution of it. */
.theme-personal .life-hero-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}

.theme-personal main#main {
  position: relative;
  z-index: 1;
}

/* The hero's second job — being the literal start of the page — lives here,
   separate from the now-fixed background layer above: normal in-flow
   content, vertically centered in its own tall block so it still reads as
   a hero section even though the canvas behind it isn't scoped to it. */
/* Top-aligned, not centered: the black hole's camera looks straight at
   the disk with no vertical offset, so its bright ring/disk crossing sits
   at a roughly constant FRACTION of viewport height (~32%-70%, verified
   at both 450px and 900px tall) regardless of absolute size — centering
   the text put it directly on the busiest, highest-contrast-against-itself
   part of the render. `max(7.5rem, 15vh)` keeps the text in the calm
   upper band on both short and tall viewports (a fixed rem alone cleared
   it on a 900px-tall desktop but crept back toward the ring on a shorter
   one, since the ring's own position scales with height and a fixed
   offset doesn't). */
.theme-personal .life-hero-inner {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  text-align: center;
  padding: max(7.5rem, 15vh) var(--space-3) var(--space-4);
  padding: max(7.5rem, 15svh) var(--space-3) var(--space-4);
}

/* Belt-and-suspenders alongside the positioning above: a subtle backdrop
   pill per line (same technique as home.css's .hero-sub-text), so text
   stays readable even if a future camera/aspect-ratio tweak shifts the
   ring back under it. */
.theme-personal .life-hero-inner h1,
.theme-personal .life-hero-inner p {
  display: inline-block;
  background: color-mix(in srgb, var(--surface) 40%, transparent);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  border-radius: var(--radius);
}

.theme-personal .life-hero-inner h1 {
  font-size: clamp(1.9rem, 4vw, 2.5rem);
  padding: 0.2em 0.5em;
  text-shadow: 0 2px 28px rgba(0, 0, 0, 0.85), 0 1px 3px rgba(0, 0, 0, 0.9);
}

.theme-personal .life-hero-inner p {
  margin: var(--space-1) auto 0;
  max-width: 480px;
  padding: 0.3em 0.7em;
  color: var(--text);
  text-shadow: 0 2px 20px rgba(0, 0, 0, 0.85), 0 1px 3px rgba(0, 0, 0, 0.9);
}

/* A horizontally-scrolling row (the "Netflix Top 10" pattern), not a
   static grid — fixed-width cards in a flex row with scroll-snap, broken
   out to full viewport width via the same negative-margin trick .life-hero
   already uses, since a row like this reads as expansive/cinematic, not
   confined to the reading column the rest of the page uses. With only two
   cards today both still fit side by side on most desktop widths (no
   scroll needed there), but the fixed card width means a narrow viewport
   can't fit both — exactly the mobile "peek of the next card, swipe for
   more" affordance this pattern is for, and it's already the right shape
   for whenever more accounts get added. Scrollbar hidden (still fully
   scrollable via touch/trackpad/shift+wheel) to match the reference. */
.theme-personal .social-grid {
  display: flex;
  gap: var(--space-2);
  width: 100vw;
  margin-left: calc(50% - 50vw);
  /* Top padding beyond what the cards themselves need is deliberate
     headroom for the hover lift below — this row's own overflow-y:hidden
     was silently clipping the top ~3px of a hovered card (border, gradient
     highlight line) off, since a card resting flush against the row's own
     edge has nowhere to go when it lifts. Confirmed directly, not just
     reasoned about: the reported "highlight line gets cut off" bug. */
  padding: var(--space-2) var(--space-3) var(--space-1) var(--nav-clearance);
  /* Real box padding alone isn't enough here: with scroll-snap-type active,
     a leading padding whose size doesn't match scroll-padding isn't a valid
     snap position, so the browser silently jumps scrollLeft to the padding
     value at rest (confirmed directly — scrollLeft read back as exactly
     84px, the padding amount, with this left unset). scroll-padding-left
     matching the real padding tells the snap machinery that inset IS the
     correct resting/snap position, so scrollLeft correctly stays at 0. */
  scroll-padding-left: var(--nav-clearance);
  overflow-x: auto;
  overflow-y: visible;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.theme-personal .social-grid::-webkit-scrollbar {
  display: none;
}

/* Translucent like the nav and the contact modal, not the solid opaque fill
   this had before — same color-mix(--surface) + blur language. First pass
   used 55% (more opaque than the modal's 45%) on the theory that a card
   has to stay legible against whatever's scrolled behind it, not a
   scrim-dimmed modal backdrop — reported back as still not reading as
   transparent, since the black hole background is fairly uniformly dark
   at the scroll depth where these cards actually sit, the exact "80% read
   as solid against a dark backdrop" problem the modal itself already hit
   once. Matched the modal's own proven 45% instead of re-guessing a new
   number; a stronger text-shadow on the label/tagline text below is the
   legibility safety net in its place. The accent-tinted border is a
   lighter version of the modal's own glow, restrained since two or more
   of these render at once. */
.theme-personal .social-card {
  --ig-grad: linear-gradient(135deg, #feda75, #e1306c 45%, #5851db);

  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-1);
  flex: 0 0 auto;
  width: min(300px, 78vw);
  scroll-snap-align: start;
  background: color-mix(in srgb, var(--surface) 45%, transparent);
  backdrop-filter: blur(22px) saturate(150%);
  -webkit-backdrop-filter: blur(22px) saturate(150%);
  border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border));
  border-radius: var(--radius-lg);
  padding: var(--space-3);
  text-decoration: none;
  color: var(--text);
  position: relative;
  overflow: hidden;
  transition: border-color var(--speed) var(--ease), transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
}

.theme-personal .social-card::before {
  content: "";
  position: absolute;
  inset: 0;
  padding: 1px;
  border-radius: inherit;
  background: var(--ig-grad);
  opacity: 0;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  transition: opacity var(--speed) var(--ease);
  pointer-events: none;
}

.theme-personal .social-card:hover,
.theme-personal .social-card:focus-visible {
  transform: translateY(-3px);
  box-shadow: 0 10px 30px -12px rgba(225, 48, 108, 0.35);
}

.theme-personal .social-card:hover::before,
.theme-personal .social-card:focus-visible::before {
  opacity: 1;
}

.theme-personal .social-card-head {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  width: 100%;
}

.theme-personal .social-icon {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  border-radius: 12px;
  background: var(--bg);
  border: 1px solid var(--border);
}

.theme-personal .social-card-body {
  min-width: 0;
}

.theme-personal .social-label {
  display: block;
  color: var(--muted);
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}

.theme-personal .social-handle {
  display: block;
  font-family: var(--font-display);
  font-size: 1.1rem;
  font-weight: 600;
  margin-top: 2px;
}

.theme-personal .social-tagline {
  display: block;
  color: var(--muted);
  font-size: 0.9rem;
  margin-top: var(--space-1);
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}

.theme-personal .social-cta {
  margin-top: var(--space-1);
  padding-top: var(--space-1);
  border-top: 1px solid var(--border);
  width: 100%;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text);
  opacity: 0.75;
  transition: opacity var(--speed) var(--ease), transform var(--speed) var(--ease);
}

.theme-personal .social-cta::after {
  content: " ↗";
  font-weight: 400;
}

.theme-personal .social-card:hover .social-cta,
.theme-personal .social-card:focus-visible .social-cta {
  opacity: 1;
  transform: translateX(2px);
}

/* Live Instagram feed (assets/js/instagram-feed.js, via Behold.so) — replaces
   the two static handle cards above (.social-card, kept for /lab/'s own
   demo, not deleted) with real photo content, per direct feedback that the
   old cards "marketed the ID, not the content." Merges both accounts'
   posts into one grid sorted by real timestamp rather than two separately
   labeled rows, matching the same consistency preference the YouTube
   section's own redesign already established. */
.theme-personal .ig-feed-grid,
.theme-personal .ig-feed-loading {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-2);
}

/* This project has hit this exact bug once already on .lab-modal: a bare
   [hidden] attribute selector loses to any class selector on specificity,
   so instagram-feed.js's "loadingEl.hidden = true" was correctly setting
   the attribute while this file's own "display: grid" above just kept
   winning — confirmed directly, not assumed, via a real screenshot still
   showing the skeleton after the JS reported it hidden. Class+attribute
   forces this to actually win. */
.theme-personal .ig-feed-grid[hidden],
.theme-personal .ig-feed-loading[hidden] {
  display: none;
}

@media (max-width: 900px) {
  .theme-personal .ig-feed-grid,
  .theme-personal .ig-feed-loading {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 600px) {
  .theme-personal .ig-feed-grid,
  .theme-personal .ig-feed-loading {
    grid-template-columns: 1fr;
  }
}

.theme-personal .ig-feed-loading span {
  display: block;
  aspect-ratio: 1;
  border-radius: var(--radius);
  background: var(--surface);
  animation: ig-feed-pulse 1.4s ease-in-out infinite;
}
.theme-personal .ig-feed-loading span:nth-child(2) { animation-delay: 0.1s; }
.theme-personal .ig-feed-loading span:nth-child(3) { animation-delay: 0.2s; }
.theme-personal .ig-feed-loading span:nth-child(4) { animation-delay: 0.15s; }
.theme-personal .ig-feed-loading span:nth-child(5) { animation-delay: 0.25s; }
.theme-personal .ig-feed-loading span:nth-child(6) { animation-delay: 0.05s; }

@keyframes ig-feed-pulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .theme-personal .ig-feed-loading span {
    animation: none;
    opacity: 0.7;
  }
}

.theme-personal .ig-feed-item {
  display: block;
  aspect-ratio: 1;
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface);
}

.theme-personal .ig-feed-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.35s var(--ease);
}

.theme-personal .ig-feed-item:hover img,
.theme-personal .ig-feed-item:focus-visible img {
  transform: scale(1.06);
}

.theme-personal .ig-feed-error {
  text-align: center;
  padding: var(--space-4) 0;
  color: var(--muted);
}

/* Small, always-visible regardless of feed state — losing a direct path to
   the real profiles (not just a specific post) would be a real regression
   from what the handle cards used to guarantee. */
.theme-personal .ig-feed-links {
  margin-top: var(--space-2);
  text-align: center;
  font-size: 0.85rem;
  color: var(--muted);
}

.theme-personal .ig-feed-links a {
  margin: 0 var(--space-1);
}

/* Click effects (sniper reticle) — see assets/js/click-effects.js. Fixed
   overlay so it works anywhere on the page, not just over the intro/cards. */
.theme-personal .click-fx-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 60;
  overflow: hidden;
}

.theme-personal .click-fx {
  position: absolute;
  width: 0;
  height: 0;
}

.theme-personal .click-fx-tick,
.theme-personal .click-fx-dot {
  position: absolute;
  left: 0;
  top: 0;
  background: var(--accent);
  border-radius: 1px;
  opacity: 1;
  transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 320ms ease;
}

.theme-personal .click-fx-dot {
  border-radius: 50%;
}

/* Each story gets the full screen to itself and its own content vertically
   centered within it — replaces the old "just enough height for its own
   content, margin-top for spacing" layout, which is what let sections read
   as one continuous, overlapping wall (confirmed directly: scrolled past
   Instagram, its own card text was still passing behind the fixed nav bar
   with no clean section boundary to rest on). No margin-top now — sections
   sit flush against each other; the height itself is the separation. */
.theme-personal .life-section {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Own top clearance so a section's heading never sits directly under the
     fixed nav pill right after a snap — nav is ~64-80px tall depending on
     width, this comfortably clears it at every size tested. */
  padding-top: max(var(--space-4), 12vh);
  padding-bottom: var(--space-4);
}

.theme-personal .life-section h2 {
  font-size: 1.1rem;
  margin: 0 0 var(--space-1);
}

.theme-personal .coming-later {
  color: var(--muted);
  font-size: 0.9rem;
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.7);
}

/* Album intro: icon/heading/caption above the slider below. */
.theme-personal .album-intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--space-1);
}

.theme-personal .album-placeholder-icon {
  width: 56px;
  height: 56px;
  opacity: 0.8;
  margin-bottom: var(--space-1);
  filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.6));
}

.theme-personal .album-placeholder-icon svg {
  width: 100%;
  height: 100%;
}

/* Smooth Scroll Slider — ported from a pasted Originkit React component
   into plain JS/CSS (photo-slider.js). This is the real, no-JS/
   reduced-motion markup: a plain horizontally-scrollable row, same
   full-bleed + scroll-snap recipe as .social-grid/.video-grid above. When
   motion is allowed, photo-slider.js adds .is-enhanced and takes over
   layout with its own absolute-positioned, auto-looping, drag/wheel-driven
   transform loop — this rule set is what that state falls back from, not
   just a loading skeleton. Placeholder tiles (no real photos yet) use the
   exact gradient-hue formula the original React component falls back to
   when it has no images either. */
.theme-personal .photo-slider {
  /* One token drives tile size everywhere (plain row, enhanced carousel,
     and photo-slider.js, which reads this computed size directly instead
     of a duplicated JS constant) — bigger from tablet up, since a 170px
     tile read as too small on a real desktop (direct feedback). */
  --tile-size: 170px;
  display: flex;
  gap: var(--space-2);
  width: 100vw;
  margin-left: calc(50% - 50vw);
  padding: var(--space-1) var(--space-3) var(--space-2) var(--nav-clearance);
  scroll-padding-left: var(--nav-clearance);
  overflow-x: auto;
  overflow-y: visible;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-y;
}

@media (min-width: 640px) {
  .theme-personal .photo-slider {
    --tile-size: 240px;
  }
}

@media (min-width: 1024px) {
  .theme-personal .photo-slider {
    --tile-size: 320px;
  }
}

.theme-personal .photo-slider::-webkit-scrollbar {
  display: none;
}

.theme-personal .photo-slide {
  position: relative;
  flex: 0 0 auto;
  width: var(--tile-size);
  height: var(--tile-size);
  border-radius: var(--radius);
  overflow: hidden;
  scroll-snap-align: start;
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}

/* Real photos render as a <button> (clicking one opens the info modal);
   placeholder tiles stay plain <div>s with nothing to show. Button-only
   reset, kept separate from the shared sizing/background rule above so
   the placeholder divs are untouched. */
.theme-personal button.photo-slide {
  display: block;
  padding: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.theme-personal button.photo-slide:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.theme-personal .photo-slide img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% calc(50% + var(--offset-y, 0px));
  display: block;
  pointer-events: none;
  user-select: none;
  /* Soft deterrent only (see photo-modal.js's own contextmenu guard) —
     removes iOS's long-press "Save/Copy Image" callout menu. */
  -webkit-touch-callout: none;
}

/* Always visible, not hover-only — hover has no equivalent on touch, so a
   hover-only affordance would leave touch visitors with zero visual cue
   that a photo is tappable (the same gap this project already fixed once
   for the YouTube play badge). A small opacity/scale lift on hover/focus
   is still a real enhancement on top, not the only signal. */
.theme-personal .photo-slide-expand {
  position: absolute;
  right: 8px;
  bottom: 8px;
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: color-mix(in srgb, var(--bg) 55%, transparent);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  color: var(--text);
  pointer-events: none;
  transition: transform var(--speed) var(--ease), background-color var(--speed) var(--ease);
}

.theme-personal button.photo-slide:hover .photo-slide-expand,
.theme-personal button.photo-slide:focus-visible .photo-slide-expand {
  transform: scale(1.12);
  background: color-mix(in srgb, var(--accent) 65%, var(--bg));
}

/* Graceful failure: if a photo's own file ever fails to load (network
   issue, anything), the <img> gets this class via its onerror attribute
   (life/index.md) instead of showing the browser's own broken-image icon.
   Falls back to the exact same gradient look already used for "no photos
   yet" placeholder tiles — reusing an existing, honest-about-being-empty
   visual rather than a new one, fixed hue so it never implies the photo's
   actual color. */
.theme-personal .photo-slide.photo-slide-broken img {
  display: none;
}

.theme-personal .photo-slide.photo-slide-broken {
  background: linear-gradient(150deg, hsl(212 30% 28%), hsl(255 30% 12%));
}

.theme-personal .photo-slide-placeholder {
  background: linear-gradient(150deg, hsl(var(--hue) 42% 34%), hsl(calc(var(--hue) + 45) 55% 10%));
}

.theme-personal .photo-slider.is-enhanced {
  position: relative;
  overflow: hidden;
  scroll-snap-type: none;
  padding: 0;
  cursor: grab;
}

.theme-personal .photo-slider.is-enhanced .photo-slide {
  position: absolute;
  top: 50%;
  left: 0;
  margin: 0;
  will-change: transform, filter;
  scroll-snap-align: none;
}

.theme-personal .photo-slider.is-enhanced.is-dragging {
  cursor: grabbing;
}

/* Photo info modal — reuses .contact-modal's own shell (tokens.css), only
   the content area is specific to this one. Sized as a real lightbox, not
   a small form-sized popup (direct feedback: "make it big... not like in
   small window") — the photo is the dominant element, caption/metadata
   secondary underneath. */
.photo-modal-panel {
  width: min(94vw, 1100px);
  max-height: min(92vh, 920px);
}

/* .contact-modal-close is absolutely positioned at top:8px, 44px tall, so
   its bottom edge sits ~52px down the panel — the title-bearing modals
   (contact/slice-blade) clear it via their own title's padding-right, but
   this modal's first real content is the image itself, with nothing above
   it to push it down. Without this margin the image's top-right corner
   sat directly under the close button (confirmed directly: overlapping
   rects at mobile width, not just a close guess) — re-verified the same
   margin still clears it at this much wider panel size. */
.photo-modal-image {
  display: block;
  width: 100%;
  max-height: min(68vh, 640px);
  object-fit: contain;
  border-radius: var(--radius);
  margin-top: 1.25rem;
  margin-bottom: var(--space-2);
  background: color-mix(in srgb, var(--bg) 40%, transparent);
  -webkit-touch-callout: none;
}

.photo-modal-image.photo-modal-image-broken {
  display: none;
}

.photo-modal-caption {
  margin: 0 0 var(--space-2);
  color: var(--text);
  font-size: 1.1rem;
  line-height: 1.55;
}

.photo-modal-caption[hidden],
.photo-modal-meta[hidden] {
  display: none;
}

.photo-modal-meta {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.3em 0.8em;
  margin: 0 0 var(--space-2);
  font-size: 0.85rem;
}

.photo-modal-meta dt {
  color: var(--muted);
}

.photo-modal-meta dd {
  margin: 0;
  color: var(--text);
}

.photo-modal-request {
  margin: 0;
  padding-top: var(--space-2);
  border-top: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.85rem;
}

/* YouTube: two channel cards plus a horizontally scrolling row of specific
   videos edited for another creator. Same visual recipe as .social-card
   (translucent dark glass, blur, brand-colored hover reveal) but kept as
   its own rule set rather than reusing .social-card directly, so nothing
   here can ever regress the already-shipped Instagram cards. Both use
   YouTube's own brand red as their hover accent — the same "a real
   external platform gets its own brand color" exception already
   established for Instagram's pink hover glow, which the site-wide
   blue-accent pass deliberately left alone for the same reason. */
/* Each channel/creator gets its own row: a small clickable name linking
   straight to that channel, directly above its own horizontal strip of
   cards — replaces the old .youtube-channels card pair that sat above all
   24 cards as one undifferentiated pile. Kartik Bansal (whose videos these
   are, per each card's own .video-title/href — he's not a channel the
   site owner runs) gets a real clickable row label for the first time
   here too, instead of only being named in a hover-only meta line. */
.theme-personal .video-row {
  margin-top: var(--space-3);
}

.theme-personal .video-row-label {
  display: inline-block;
  font-family: var(--font-display);
  font-size: 0.95rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text);
  text-decoration: none;
  opacity: 0.8;
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.7);
  transition: opacity var(--speed) var(--ease), color var(--speed) var(--ease);
}

.theme-personal .video-row-label::after {
  content: " ↗";
  font-weight: 400;
}

.theme-personal .video-row-label:hover,
.theme-personal .video-row-label:focus-visible {
  opacity: 1;
  color: #ff0000;
}

.theme-personal .video-grid-intro {
  margin-top: var(--space-3);
  color: var(--muted);
  font-size: 0.9rem;
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.7);
}

/* Same full-bleed horizontal scroll-snap row as .social-grid (see its own
   comment for why the padding is paired with a matching scroll-padding —
   a scroll-snap container silently jumps scrollLeft to an unmirrored
   leading padding otherwise), kept separate so this row and the Instagram
   one can change independently. Extra top/bottom padding (beyond what the
   content itself needs) is deliberate headroom for the hover scale-up
   below — .social-card hit exactly this bug once already (a hover lift
   clipped clean off by the row's own overflow-y: hidden, since it had no
   slack above the resting card); this row gets that slack from the start.
   Three of these now sit inside #youtube (one per .video-row, see above) —
   an earlier pass had collapsed them into one unified 24-card row because
   three separate grids plus a per-card .video-meta line pushed #youtube's
   real height to 1261px against a 768px viewport, confirmed directly via
   getBoundingClientRect(), with no snap point inside that overflow. This
   time the per-card .video-meta line is gone (the channel name now lives
   once, in the row's own label, not repeated on every card under it),
   which alone claws back enough height that three grids plus three labels
   fits #youtube's real budget with room to spare — confirmed the same way,
   via getBoundingClientRect() at both mobile and desktop after building,
   not just estimated from the CSS. margin-top is small (var(--space-1))
   since .video-row-label directly above already carries the bulk of the
   spacing via its own row's var(--space-3) margin-top. */
.theme-personal .video-grid {
  display: flex;
  gap: var(--space-2);
  margin-top: var(--space-1);
  width: 100vw;
  margin-left: calc(50% - 50vw);
  padding: var(--space-1) var(--space-3) var(--space-1) var(--nav-clearance);
  scroll-padding-left: var(--nav-clearance);
  overflow-x: auto;
  overflow-y: visible;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.theme-personal .video-grid::-webkit-scrollbar {
  display: none;
}

/* Every card — regardless of source (RED FILMS' 16:9 event films, 1mCreative's
   vertical Shorts, Kartik Bansal's dance edits) — gets the identical box.
   YouTube's own hqdefault.jpg is the same flat 480x360 raster for a Short
   as for a long-form video (confirmed directly, not assumed), so cropping
   every thumbnail into one shared 16:9 frame via object-fit:cover isn't
   losing anything format-specific — there was never a "true vertical"
   source image to begin with. */
.theme-personal .video-card {
  flex: 0 0 auto;
  width: 160px;
  scroll-snap-align: start;
  text-decoration: none;
  color: var(--text);
  transition: transform var(--speed) var(--ease), z-index 0s var(--speed);
}

/* Netflix-style hover pop, toned down from an earlier, larger-card version
   of this same idea — a compact playlist row doesn't need (or have vertical
   room for) a dramatic scale-up, and a smaller one reduces how much
   headroom .video-grid has to reserve against clipping. */
.theme-personal .video-card.is-hover-active {
  transform: scale(1.08);
  z-index: 5;
}

.theme-personal .video-thumb {
  position: relative;
  display: block;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--bg);
  box-shadow: 0 0 0 rgba(0, 0, 0, 0);
  transition: border-color var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
}

.theme-personal .video-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: opacity var(--speed) var(--ease);
}

/* The live preview iframe (added/removed by video-hover.js on hover intent,
   never present in the static markup) sits over the poster image rather
   than replacing it, so there's always an instant frame to show before the
   embed has actually loaded — removing the iframe on hover-out just lets
   the still image show through again, real playback stops immediately
   since the iframe itself is gone, not just visually hidden. */
.theme-personal .video-thumb iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  pointer-events: none;
}

/* A small centered badge, not a full-thumbnail scrim — visible by default,
   not gated behind :hover/:focus-visible. That gate meant touch visitors
   never saw any "this is a video" cue at all (neither state a tap can
   trigger), confirmed directly: computed opacity read 0 at rest with
   nothing to reveal it on a touch device. Real platforms (YouTube, Vimeo)
   all show a persistent play affordance on thumbnails for the same reason. */
.theme-personal .video-play {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: rgba(0, 0, 0, 0.55);
  transition: transform var(--speed) var(--ease), background var(--speed) var(--ease), opacity var(--speed) var(--ease);
}

.theme-personal .video-play svg {
  width: 14px;
  height: 14px;
}

.theme-personal .video-card:hover .video-thumb,
.theme-personal .video-card:focus-visible .video-thumb,
.theme-personal .video-card.is-hover-active .video-thumb {
  border-color: #ff0000;
  box-shadow: 0 16px 36px -14px rgba(255, 0, 0, 0.45);
}

.theme-personal .video-card:hover .video-play,
.theme-personal .video-card:focus-visible .video-play {
  transform: translate(-50%, -50%) scale(1.15);
  background: rgba(0, 0, 0, 0.75);
}

/* Once a real preview starts playing, the static play badge would just sit
   on top of it doing nothing useful — hide it for that state only, not
   the plain hover state above it. */
.theme-personal .video-card.is-hover-active .video-play {
  opacity: 0;
}

/* Playlist-style: clamped to a fixed line count (not unbounded wrapping),
   so every card is still the same height regardless of how long its real
   title is — the full text is still the link's accessible name either way.
   Two lines, not one: several real titles here only differ in their last
   few words ("SPARSH 2K18 Pre-Movie" vs "SPARSH 2K18 Theme Teaser"), and a
   single-line clamp truncated both down to the identical "SPARSH 2K18...",
   reading as duplicate cards at a glance. */
.theme-personal .video-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
  margin-top: var(--space-1);
  font-size: 0.85rem;
  font-weight: 600;
  line-height: 1.3;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}

/* Left-edge jump nav — bare floating icons, deliberately no pill/background
   (dropped per direct feedback: ".site-header"'s translucent-pill language
   was the first instinct, but this is meant to read as icons floating over
   the page, not another chrome-y control). Legibility against the busy,
   varying Black Hole background comes from a drop-shadow on each icon
   instead — the same job text-shadow already does for every other piece of
   foreground text on this page, just the icon-filter equivalent. The
   colorful-vs-monochrome swap is pure CSS (grayscale filter on .is-active),
   toggled by section-nav.js's IntersectionObserver per section's own
   visibility — works the same way regardless of whether an icon is drawn
   with a brand gradient (Instagram/YouTube) or flat fills (the generic
   Album glyph), so no second "gray" copy of any icon asset is needed.
   scroll-margin-top on .social-grid/.life-section keeps a clicked section
   from landing flush against the viewport top. */
.theme-personal .section-nav {
  position: fixed;
  left: var(--space-2);
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.theme-personal .section-nav-link {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  transition: transform var(--speed) var(--ease);
}

.theme-personal .section-nav-link:hover,
.theme-personal .section-nav-link:focus-visible {
  transform: scale(1.15);
}

.theme-personal .section-nav-icon {
  display: flex;
  width: 22px;
  height: 22px;
  /* --icon-gray/--icon-glow let .is-active and :hover each override just
     their own piece of the filter chain independently, instead of needing
     four full filter strings for every active x hover combination. */
  --icon-gray: grayscale(1);
  --icon-glow: drop-shadow(0 0 0 transparent);
  filter: var(--icon-gray) drop-shadow(0 1px 6px rgba(0, 0, 0, 0.7)) var(--icon-glow);
  opacity: 0.55;
  transition: filter var(--speed) var(--ease), opacity var(--speed) var(--ease);
}

.theme-personal .section-nav-icon svg {
  width: 100%;
  height: 100%;
}

.theme-personal .section-nav-link.is-active .section-nav-icon {
  --icon-gray: grayscale(0);
  opacity: 1;
}

/* Blue glow on hover/focus — the site's own accent, not a new color —
   independent of grayscale/active state (drop-shadow's own color isn't
   affected by a preceding grayscale() in the same filter chain), so it
   works the same whether the icon is currently colorful or not. */
.theme-personal .section-nav-link:hover .section-nav-icon,
.theme-personal .section-nav-link:focus-visible .section-nav-icon {
  --icon-glow: drop-shadow(0 0 10px var(--accent));
}

@media (max-width: 640px) {
  .theme-personal .section-nav {
    left: var(--space-1);
  }

  /* Below main's own 780px max-width, .life-section's normal-flow content
     sits close enough to the true left edge to pass behind the floating
     nav mid-scroll — confirmed with a real overlap at 375px width,
     scrollY 525 (the Youtube heading crossing the nav's own vertical
     band). Not needed above this breakpoint: main's centered column
     already clears the nav with plenty of room once its 780px cap binds. */
  .theme-personal .life-section {
    padding-left: var(--nav-clearance);
  }
}
