/* ---------- Tokens ---------- */
:root {
  --color-bg: #0A1210;
  --color-card: #10201C;
  --color-border: #1F4B3A;
  --color-text: #F2F5F3;
  --color-text-soft: #8FA69C;
  --color-accent: #00E676;
  /* Text-safe accent variant — identical to --color-accent everywhere
     except inside .tone-light sections (see below), where raw #00E676
     text would fail contrast against a light background. Only the few
     rules that render the accent AS TEXT on a raw (non-.card) surface
     reference this token; backgrounds/borders/icon fills keep using
     --color-accent directly since those never have a text-contrast
     requirement of their own. */
  --color-accent-ink: var(--color-accent);

  /* Light-section palette — used by .tone-light (alternating "scenes",
     see below). Kept in the same green/black family as the dark
     palette (a muted mint neutral, not a divorced gray/white) so light
     sections read as the same brand, just inverted. */
  --light-bg: #EDF3EF;
  --light-bg-deep: #DCE9E0;
  --light-text: #0A1F16;
  --light-text-soft: #3E5A4C;
  --light-accent-ink: #005E2F;
  --light-border: rgba(10, 40, 25, 0.14);

  --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  /* Type scale — every font-size in this file must reference one of these */
  --text-eyebrow: 13px;
  --text-h1: clamp(2.5rem, 6vw, 4.5rem);
  --text-h2: 2rem;
  --text-body: 1.0625rem;   /* 17px */
  --text-chip: 0.875rem;    /* 14px */
  --text-caption: 0.9375rem; /* 15px */
  --text-quote: var(--text-body); /* same size as body text; distinguished by style, not size */
  --text-badge: clamp(1.375rem, 1.1rem + 1.2vw, 1.625rem); /* hero role pill — near-equal billing with the name */
  --text-feature: 1.375rem; /* Perspectivas headlines */

  --wrap-max: 2000px; /* raised from 1760px — still too much side margin on wide (1920px+) monitors */
  --wrap-narrow: 860px; /* available narrow-column utility (.wrap--narrow) — not currently used by any section */
  --wrap-compact: 1200px; /* ~88% of wrap-max — used to vary rhythm on Herramientas only */
  --rail-width: 96px; /* space reserved for the vertical side-rail on very wide viewports */
  --text-quote-lg: clamp(1.75rem, 1.3rem + 2.2vw, 2.75rem); /* Sobre mí/Enfoque pull-quote */

  /* Spacing scale — multiples of 8px */
  --space-1: 0.5rem;  /* 8px */
  --space-2: 1rem;    /* 16px */
  --space-3: 1.5rem;  /* 24px */
  --space-4: 3rem;    /* 48px */
  --space-5: 4rem;    /* 64px */
  --space-6: 5rem;    /* 80px */

  /* Single source of truth for vertical section padding — every band
     (hero, main sections, footer) uses this, identically for top AND
     bottom, no exceptions. Previously hero used an asymmetric
     var(--space-6) var(--space-5) pair (80px/64px) and footer used a
     smaller var(--space-4) (48px) than every other section's 80px —
     both real, measured mismatches, not just a visual impression. */
  --section-pad-y: var(--space-4);
  --section-pad-y-lg: var(--space-6);

  /* --radius stays for small UI chrome (form fields, skip-link, nav
     toggle) — untouched by this pass. --radius-lg/--radius-sm are the
     2-value system for content cards/images vs. small nested elements,
     replacing several one-off values that had drifted (10-18px) across
     the file. */
  --radius: 8px;
  --radius-lg: 20px;
  --radius-sm: 12px;
}

/* ---------- Reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
/* Real fix (confirmed on real hardware, Mac, via a live before/after
   test in DevTools): overflow-x:hidden on BOTH html AND body creates
   two nested clip/scroll containers, which Chromium has a known
   revalidation cost for — and this doubled clipping existed only to
   paper over the .ray SVGs (see script.js randomizeRay) being able to
   generate a left position + width that pushes past the viewport
   edge. Fixed at the source: their position is now clamped in JS so
   it can never overflow, so a single overflow-x:hidden (kept on body,
   the more specific container) is enough as a backstop — no second
   clipping container needed. #rays also gets `contain:paint` below as
   defense-in-depth, isolating its own clipping without body/html
   needing to share that responsibility. */
html { scroll-behavior: smooth; }
body { overflow-x: hidden; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Replaces the old #rays (6 animated SVGs) + .ambient-glow (3 animated
   radial-gradient divs) — both confirmed, over several rounds of
   real-device profiling, to be the source of scroll jank on this page
   (see the removed CSS/JS history for the investigation).

   Measured two failed attempts before landing here (CDP
   Performance.getMetrics, 60-tick wheel scroll, each repeated twice
   for consistency, compared against a true flat background):
   1. Three radial-gradients on body, percentage-positioned across the
      whole ~5000px+ document: 37-38 frames over the 33ms budget vs
      14-18 flat, avg fps ~33 vs ~37.
   2. Just a single linear-gradient on body (simpler per-pixel math,
      same idea — a page-spanning gradient): still 34-35 over-budget
      frames vs 16-17 flat. Not a radial-vs-linear complexity problem —
      ANY background-image spanning the whole document on body seems to
      disable a cheap fast-path Chromium has for a solid document
      background-color, forcing normal paint/tile work proportional to
      how much of it has scrolled by.
   3. What worked next: body keeps a plain solid background-color (fast
      path intact), and each accent gradient lived on the individual
      SECTION it decorated instead (.hero, #trayectoria, .footer) —
      each bounded to that section's own, much smaller box. Measured
      statistically indistinguishable from a true flat background.

   Current iteration: every major section gets its OWN backdrop instead
   of one shared one, each a subtly different tone within the same
   green/black palette (a "scene change" per section on scroll, no
   scroll-snap — see .section-backdrop below for the full story on why
   hero/footer use a different positioning technique than everything
   else). */
body {
  position: relative;
  margin: 0;
  color: var(--color-text);
  font-family: var(--font-body);
  font-size: var(--text-body);
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  /* Light gray canvas — the "framed" layout's exterior. The actual
     site lives inside .page-frame with its own dark-green backdrop;
     a light exterior makes that frame boundary read clearly instead
     of blending into a dark margin. Nothing renders text directly on
     this color (it's only ever visible as the margin strip around
     .page-frame), so there's no legibility case to check here beyond
     "does it look like a deliberate frame, not a mistake" — it does. */
  background-color: #D8DAD7;
}

/* "Framed" layout: the whole site sits inside a centered container with
   the gray body visible around it on desktop; full-bleed (no margin) on
   mobile. NOT overflow:hidden here — confirmed empirically (temporarily
   adding it and checking a sticky child's getBoundingClientRect) that
   overflow:hidden on ANY ancestor, even the immediate parent, fully
   breaks position:sticky for descendants (the child just scrolls away
   like a normal block instead of sticking at top:0). Rounded corners
   are handled per-section instead — see .hero/.footer below — so this
   outer wrapper only needs to provide the margin. */
.page-frame {
  position: relative;
  margin: 24px;
}
@media (max-width: 767px) {
  .page-frame { margin: 0; }
}

/* ---------- Per-section backdrops ("scenes") ----------
   Every major section gets its own background element instead of one
   shared page-wide one — a subtle tone/position shift per section
   creates a sense of scene change while scrolling, with NO scroll-snap
   and NO forced section heights; scroll stays completely free.

   Two different techniques, chosen per section:

   1. .section-backdrop (the default): position:sticky, exactly the
      technique already validated for the old single page-wide
      backdrop — cheap because a stuck sticky element is composited,
      not repainted, during scroll. Used for every MIDDLE section
      (never at the true top/bottom of the page, so no corner-rounding
      needed, so no overflow:hidden needed, so no conflict with
      sticky).
   2. .hero, .footer, and .contacto specifically: these use
      position:absolute instead, sized to their OWN (short) section
      rather than the viewport.
      - .hero/.footer need this for rounded corners (they sit at the
        page's actual top/bottom edge, which needs overflow:hidden,
        which breaks sticky, per the finding above).
      - .contacto needs it for a different, empirically-found reason:
        it's the last STICKY-technique section before .footer, which
        doesn't use sticky at all. Measured directly (disabling each
        backdrop one at a time and comparing document.scrollHeight
        against .page-frame's own rendered height): with Contacto
        sticky, the gap between them grew with viewport height — 24px
        (matching the frame's intentional margin) at a 900px-tall
        viewport, but 260px at 1200px and worse on taller screens —
        while every OTHER sticky section (each followed by another
        sticky section) stayed flat at 24px regardless of viewport
        height. A sticky element's box still participates in normal
        flow/margin-collapsing at its static position even while
        visually "stuck" elsewhere — that bookkeeping only closes out
        cleanly when the box's own containing section ends AND the
        next sibling also re-establishes a fresh sticky/static
        position; .footer's non-sticky absolute backdrop never does
        that handoff, so Contacto's negative margin was leaking into
        the document's total scrollHeight without .page-frame's own
        rendered height (which auto-sizes off normal in-flow content)
        ever reflecting it. Switching Contacto to the same
        static-per-section technique as hero/footer removes the
        mismatched handoff entirely — verified the gap returns to the
        flat 24px baseline at every viewport height re-tested (600 to
        1200px). This is the same technique already validated as cheap
        in an earlier round (section-scoped static gradients) — no
        "persists while scrolling" effect, but all three of these
        sections are short enough that a visitor barely scrolls WITHIN
        them anyway, so the difference isn't really perceptible. */
.section-backdrop {
  position: sticky;
  top: 0;
  /* svh (small viewport height) first, with a plain-vh fallback for
     browsers that don't support it — vh on mobile Safari/Chrome tracks
     the LARGEST viewport (address bar hidden) and gets recalculated as
     the bar shows/hides during scroll, so a height:100vh box and its
     margin-bottom:-100vh sibling can briefly disagree with each other
     around that resize, leaving a few px of uncancelled height per
     section; stacked across 7 backdrops that's enough to read as a
     stray gap by the time you reach the bottom of the page. svh is the
     SMALLEST possible viewport (bar always accounted for), so it never
     changes mid-scroll and the two values always cancel exactly. */
  height: 100vh;
  height: 100svh;
  margin-bottom: calc(-100vh - 1px);
  margin-bottom: calc(-100svh - 1px);
  z-index: -2;
  pointer-events: none;
  will-change: transform;
}
/* Root cause of a real, confirmed-with-pixel-sampling gap of neutral
   body-gray showing above the colored band on every STICKY-technique
   section (most visible on short ones like Especialización, but
   present on all of them): padding-block lives on the .section itself,
   which pushes EVERY child's flow-start position down by that amount —
   including .section-backdrop, the FIRST child. .hero/.footer/.contacto
   don't have this problem because they use position:absolute+inset:0,
   which is sized to the containing block's padding box regardless of
   the parent's own padding; a STICKY element's static (unstuck)
   position, unlike absolute, is a normal-flow position and so DOES get
   pushed by padding-top like any other child — meaning the backdrop's
   natural top sits var(--section-pad-y) below the section's true top
   edge, leaving exactly that much unstyled gap visible whenever the
   backdrop hasn't stuck yet (confirmed via pixel sampling: body-gray
   (216,218,215) above the backdrop's own color, transitioning to it at
   exactly the padding-top offset).
   Fix: pull the backdrop back up by that same amount (margin-top:
   negative), then push its next sibling (.wrap, which is what visually
   needs the padding) back down by the identical amount so the actual
   CONTENT still starts exactly where it did before — only the backdrop
   itself now spans the full section including the padding zone. Scoped
   explicitly to the 6 sticky-technique sections (not a blanket
   ".section-backdrop + .wrap" rule) so it can never accidentally reach
   hero/footer/contacto's wraps, which must NOT get this compensation
   (they have no padding-push problem to compensate for). */
.section-backdrop--specialization,
.section-backdrop--sobre-mi,
.section-backdrop--perspectivas,
.section-backdrop--experiencia,
.section-backdrop--formacion,
.section-backdrop--herramientas {
  margin-top: calc(-1 * var(--section-pad-y));
}
.section-backdrop--specialization + .wrap,
.section-backdrop--sobre-mi + .wrap,
.section-backdrop--perspectivas + .wrap,
.section-backdrop--experiencia + .wrap,
.section-backdrop--formacion + .wrap,
.section-backdrop--herramientas + .wrap {
  margin-top: var(--section-pad-y);
}
@media (min-width: 900px) {
  .section-backdrop--specialization,
  .section-backdrop--sobre-mi,
  .section-backdrop--perspectivas,
  .section-backdrop--experiencia,
  .section-backdrop--formacion,
  .section-backdrop--herramientas {
    margin-top: calc(-1 * var(--section-pad-y-lg));
  }
  .section-backdrop--specialization + .wrap,
  .section-backdrop--sobre-mi + .wrap,
  .section-backdrop--perspectivas + .wrap,
  .section-backdrop--experiencia + .wrap,
  .section-backdrop--formacion + .wrap,
  .section-backdrop--herramientas + .wrap {
    margin-top: var(--section-pad-y-lg);
  }
}
/* Floating rounded-card treatment for the 5 sections the user singled
   out (Hero, Sobre mí, Experiencia, Herramientas, Footer) — margin on
   all 4 sides plus full corner rounding, gray page background visible
   between each. Hero/Footer already sit at the page's true top/bottom
   edge and use position:absolute backdrops, so plain overflow:hidden
   is fine for them (no sticky to break). Sobre mí/Experiencia/
   Herramientas use the sticky backdrop technique instead — confirmed
   via direct testing that overflow:hidden breaks position:sticky on a
   descendant (same finding from earlier rounds) but clip-path does
   NOT: a sticky child's getBoundingClientRect().top stayed 0 (properly
   stuck) with clip-path applied to its ancestor, so these three use
   clip-path for the rounding instead. */
.hero,
.footer,
#sobre-mi,
#experiencia,
#herramientas {
  /* Full width of .page-frame's own content area — same as every other
     section. These 5 used to additionally narrow by 48px on top of
     that (width: calc(100% - 48px)) for extra breathing room around
     the floating card, but that made them measurably narrower than
     the other sections (1824px vs 1872px at a 1920px viewport),
     breaking the page's outer-width consistency. Kept: vertical
     spacing and rounded corners — just without the extra horizontal
     inset. */
  margin-block: 24px;
  border-radius: 28px;
}
.hero,
.footer {
  overflow: hidden;
}
#sobre-mi,
#experiencia,
#herramientas {
  clip-path: inset(0 round 28px);
}
@media (max-width: 767px) {
  .hero,
  .footer,
  #sobre-mi,
  #experiencia,
  #herramientas {
    width: 100%;
    margin: 0;
    border-radius: 0;
  }
  #sobre-mi,
  #experiencia,
  #herramientas {
    clip-path: none;
  }
}
.section-backdrop--hero,
.section-backdrop--footer,
.section-backdrop--contacto {
  position: absolute;
  inset: 0;
  height: auto;
  margin-bottom: 0;
}

.section-backdrop--hero {
  background:
    radial-gradient(ellipse 1200px 900px at 85% -8%, rgba(0, 230, 118, 0.65), rgba(0, 230, 118, 0.18) 32%, transparent 58%),
    radial-gradient(ellipse 480px 400px at -14% 58%, rgba(0, 230, 118, 0.6), rgba(0, 230, 118, 0.14) 22%, transparent 46%),
    linear-gradient(160deg, #05140C 0%, #0A2A19 45%, #0D3A22 75%, #05140C 100%);
}
.section-backdrop--sobre-mi {
  background:
    radial-gradient(ellipse 300px 260px at -6% -8%, rgba(0, 230, 118, 0.55), rgba(0, 230, 118, 0.08) 14%, transparent 32%),
    linear-gradient(200deg, #05140C 0%, #0B2D1B 55%, #05140C 100%);
}
.section-backdrop--experiencia {
  background:
    radial-gradient(ellipse 380px 320px at -4% -6%, rgba(0, 230, 118, 0.55), rgba(0, 230, 118, 0.1) 16%, transparent 38%),
    linear-gradient(200deg, #05140C 0%, #0D2A1C 55%, #05140C 100%);
}
.section-backdrop--herramientas {
  background:
    radial-gradient(ellipse 700px 550px at 50% 0%, rgba(0, 230, 118, 0.5), rgba(0, 230, 118, 0.14) 30%, transparent 58%),
    linear-gradient(185deg, #05140C 0%, #0A2419 100%);
}
.section-backdrop--footer {
  background:
    radial-gradient(ellipse 700px 480px at 75% 100%, rgba(0, 230, 118, 0.42), transparent 55%),
    linear-gradient(180deg, #05140C 0%, #0A2016 100%);
}

/* TEST — tint opacity dropped to 0 on all 4 light sections at the
   user's request, to compare flat neutral vs. tinted. Ellipse
   size/position kept as-is (only alpha zeroed) so this is a one-line
   revert per section if the test doesn't stick. */
.section-backdrop--specialization {
  background:
    radial-gradient(ellipse 700px 550px at 12% 100%, rgba(0, 230, 118, 0), transparent 55%),
    linear-gradient(175deg, #D8DAD7 0%, #D8DAD7 100%);
}
.section-backdrop--perspectivas {
  background:
    radial-gradient(ellipse 800px 650px at 92% 12%, rgba(0, 230, 118, 0), transparent 55%),
    linear-gradient(195deg, #D8DAD7 0%, #D8DAD7 100%);
}
.section-backdrop--formacion {
  background:
    radial-gradient(ellipse 900px 700px at 95% 15%, rgba(0, 230, 118, 0), transparent 55%),
    linear-gradient(195deg, #D8DAD7 0%, #D8DAD7 100%);
}
.section-backdrop--contacto {
  background:
    radial-gradient(ellipse 800px 650px at 85% 92%, rgba(0, 230, 118, 0), transparent 55%),
    linear-gradient(195deg, #D8DAD7 0%, #D8DAD7 100%);
}

/* ---------- Light "scenes" ----------
   Alternates with the dark sections above so scrolling reads as a
   rhythm of light/dark rather than one long dark page. Applied to a
   section via .tone-light, which flips the shared text tokens
   (--color-text, --color-text-soft, --color-border, --color-accent-ink)
   to their light-mode values — every rule elsewhere in this file that
   already reads those tokens (h1-h4, .lede, .timeline__org, etc.)
   inherits the flip automatically, no per-component light/dark
   duplication needed.

   The one thing that must NOT inherit the flip: components that carry
   their OWN dark background regardless of section tone (.card and
   friends) — seeing --color-text flip to dark-on-dark inside them
   would break legibility. Those re-assert the dark-mode values locally
   (see "dark islands" below), so the light/dark tone only ever affects
   text sitting directly on the raw section backdrop. */
.tone-light {
  --color-text: var(--light-text);
  --color-text-soft: var(--light-text-soft);
  --color-border: var(--light-border);
  --color-accent-ink: var(--light-accent-ink);
}

/* "Dark islands": components with their own OPAQUE dark background
   regardless of the section tone they sit in — re-assert dark-mode
   text tokens locally so .tone-light never leaks into them (see
   comment above). .card is deliberately NOT in this list: it's only
   58%-opaque, so over a light backdrop it composites to a washed-out
   grey-green, not a reliable dark surface — forcing light text onto
   that produced illegible copy in testing. It gets its own light-mode
   surface below instead. */
.card--interactive,
.specialization__face,
.timeline__project,
.linkedin-posts__card,
.field input,
.field textarea {
  --color-text: #F2F5F3;
  --color-text-soft: #8FA69C;
  --color-border: #1F4B3A;
  --color-accent-ink: var(--color-accent);
}
/* .card inside a light scene: a real light surface (not a dark island)
   so the 58%-opacity trick that lets the dark backdrop bleed through
   on dark sections doesn't instead wash out to grey here. Text follows
   .tone-light's dark-mode token values automatically (inherited). */
.tone-light .card {
  background: rgba(255, 255, 255, 0.75);
  border-color: var(--light-border);
}
.tone-light .card:hover,
.tone-light .card:focus-within {
  box-shadow: 0 12px 32px rgba(10, 40, 25, 0.12), 0 0 0 1px rgba(0, 230, 118, 0.25);
}

/* Subtle film-grain texture over the background only (sits behind cards,
   which are opaque, so it never touches card content).
   mix-blend-mode:overlay used to sit here — measured (A/B scroll test,
   isolating each fixed layer individually) to be the actual cause of a
   real scroll-cost regression once .page-backdrop was added: overlay
   blending is computed at COMPOSITE time against whatever is beneath
   it, every frame, not painted once like a normal layer — so a plain
   fixed layer became expensive again as soon as something colorful
   sat behind it. The backdrop ALONE (no blend-mode) measured as cheap
   as a flat background; noise ALONE (blend-mode, but only over the
   flat body color) was mostly fine too; the two together were not.
   Switched to plain opacity (no blend-mode) — slightly flatter-looking
   grain, but statistically identical to a flat background on scroll. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0.035;
  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.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-repeat: repeat;
}

img { max-width: 100%; display: block; }
ul, ol { list-style: none; margin: 0; padding: 0; }
a { color: var(--color-accent-ink); }
a:hover { color: #6bffab; }

h1, h2, h3, h4 {
  font-family: var(--font-body);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.12;
  margin: 0 0 var(--space-2);
  color: var(--color-text);
}
h1 { font-size: var(--text-h1); }
h2 { font-size: var(--text-h2); }
h3 { font-size: var(--text-body); font-weight: 600; letter-spacing: -0.01em; }
h4 { font-size: var(--text-feature); margin-bottom: var(--space-1); }

/* Section H2 stagger reveal — see setupHeadingStagger in script.js.
   Each word-group span starts offset/invisible with its own
   transition-delay (set inline per span), so they reveal in sequence
   once the heading's parent gets .is-visible from the one-shot
   IntersectionObserver. display:inline-block is required for
   transform to apply to an inline element. */
.stagger-word {
  display: inline-block;
  opacity: 0;
  transform: translateY(0.35em);
  transition: opacity 0.5s ease, transform 0.5s ease;
}
h2.is-visible .stagger-word {
  opacity: 1;
  transform: translateY(0);
}

/* ---------- Layout helpers ---------- */
.wrap {
  max-width: var(--wrap-max);
  margin-inline: auto;
  padding-inline: var(--space-3);
}
.wrap--narrow { max-width: var(--wrap-narrow); }

/* One consistent padding-block for every section tier, using the same
   --section-pad-y token that .hero and .footer also use below — see
   the token comment for why this needs to be a single shared value
   rather than per-section tuning. All three selectors are kept
   (rather than merged into .section alone) so the modifier classes
   stay meaningful in the markup even though they now share one value. */
.section,
.section--compact,
.section--roomy {
  padding-block: var(--section-pad-y);
  position: relative;
  scroll-margin-top: 84px;
}
@media (min-width: 900px) {
  .section,
  .section--compact,
  .section--roomy {
    padding-block: var(--section-pad-y-lg);
  }
}

/* Herramientas used to run narrower than the rest via this class, which
   broke outer-width consistency across the page and has been removed
   from it (now just .wrap, same as every other section). Only the
   hidden #linkedin-posts section still carries this class. */
.wrap--compact { max-width: var(--wrap-compact); }

/* ---------- Accessibility ---------- */
.skip-link {
  position: absolute;
  left: -999px;
  top: auto;
  background: var(--color-accent);
  color: var(--color-bg);
  font-weight: 700;
  padding: 0.75em 1.25em;
  z-index: 100;
  border-radius: var(--radius);
}
.skip-link:focus {
  left: var(--space-2);
  top: var(--space-2);
}

:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
}

/* ---------- Cards ---------- */
.card {
  position: relative;
  /* Semi-transparent instead of the old solid --color-card: with the
     vivid .page-backdrop gradient now behind everything, an opaque
     card read as a flat dark hole punched out of it. Letting the
     gradient bleed through softly keeps cards feeling like part of the
     same surface. Verified WCAG AA still holds against text at the
     brightest point of the gradient (see verification notes). */
  background: rgba(16, 32, 28, 0.58);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: 2rem;
  transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
@media (min-width: 700px) {
  .card { padding: 3rem; }
}
.card:hover,
.card:focus-within {
  transform: translateY(-2px);
  border-color: var(--color-accent);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 230, 118, 0.2);
}
.card--interactive:hover,
.card--interactive:focus-within {
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 230, 118, 0.25), 0 24px 48px rgba(0, 230, 118, 0.16);
}
@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
}

/* ---------- Scroll-in reveal (progressive enhancement, see script.js) ---------- */
.card.card-init {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.card.card-init.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* ---------- Hero ---------- */
.hero {
  position: relative;
  padding-block: var(--section-pad-y);
}
@media (min-width: 900px) {
  .hero { padding-block: var(--section-pad-y-lg); }
}
.hero__grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-4);
  align-items: center;
}
.eyebrow {
  font-size: var(--text-eyebrow);
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--color-accent-ink);
  margin: 0 0 var(--space-2);
}
.hero__nickname {
  font-size: var(--text-caption);
  font-weight: 500;
  font-style: italic;
  color: var(--color-text-soft);
  margin: 0 0 var(--space-2);
}
.badge {
  display: inline-block;
  font-weight: 700;
  font-size: var(--text-badge);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--color-text);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: 0.75rem 1.75rem;
  max-width: 34ch;
  margin: 0 0 var(--space-3);
}
/* Result-oriented benefit line — sits between the specialty badge and
   the smaller AI & Automation Specialist caption, so it reads as the
   "so what" right after the "what I am". */
.hero__benefit {
  font-size: var(--text-body);
  font-weight: 500;
  color: var(--color-text);
  max-width: 42ch;
  margin: 0 0 var(--space-2);
}
.hero__subtitle {
  font-size: var(--text-caption);
  color: var(--color-text-soft);
  margin: 0 0 var(--space-3);
}
.hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
/* .btn--outline's margin-top exists for stand-alone contexts (e.g. the
   TRAMA CTA under a chip list) where it isn't a sibling in a gapped flex
   row; here that gap already provides spacing, and align-items:stretch's
   default would otherwise have inflated .btn--primary's height to match
   the outline button's taller margin box, producing an egg-shaped pill. */
.hero__actions .btn--outline { margin-top: 0; }
.hero__photo {
  position: relative;
  z-index: 1;
  justify-self: center;
  width: min(280px, 60vw);
}
.hero__photo img {
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  object-position: center 22%;
  border-radius: 50%;
  border: 1px solid var(--color-border);
  animation: photo-halo 3.6s ease-in-out infinite;
  /* Persistent compositor-layer hint: keeps this one perpetually-
     animating element (the only one left — rays/ambient-glow were
     removed) promoted to its own GPU layer instead of Chromium
     promoting/demoting it dynamically. */
  will-change: box-shadow;
}
@keyframes photo-halo {
  0%, 100% { box-shadow: 0 0 0 0 rgba(0, 230, 118, 0.3), 0 0 18px 2px rgba(0, 230, 118, 0.12); }
  50%      { box-shadow: 0 0 0 5px rgba(0, 230, 118, 0.1), 0 0 34px 10px rgba(0, 230, 118, 0.26); }
}

@media (min-width: 780px) {
  .hero__grid {
    grid-template-columns: 1.4fr 1fr;
    /* More horizontal breathing room between the text block and the
       photo than the base --space-4 gap used at every other breakpoint
       — reinforces a wide/open feel now that --wrap-max is bigger,
       instead of everything huddling toward the center. */
    gap: var(--space-5);
  }
  .hero__photo { justify-self: end; }
}
@media (min-width: 1400px) {
  .hero__grid { gap: var(--space-6); }
}

/* ---------- Buttons ---------- */
.btn {
  display: inline-block;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: var(--text-caption);
  text-decoration: none;
  padding: 1rem 1.5rem;
  border-radius: 999px;
  border: 1px solid transparent;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
/* Magnetic CTA effect (see script.js) — transform gets its own entry in
   the same transition list so it never touches the properties above.
   Default duration is the smooth "return to center" on mouseleave;
   .is-magnet-tracking (added only while actively hovering) drops it to
   0s so the button follows the cursor without lag while inside the
   button's bounds. */
[data-magnetic] {
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.3s ease;
}
[data-magnetic].is-magnet-tracking {
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0s linear;
}
.btn--primary {
  background: var(--color-accent);
  color: var(--color-bg);
}
/* The global `a:hover{color:#6bffab}` rule (styles.css:212, meant for
   inline text links) outranks `.btn--primary{color:...}` by specificity
   on hover/focus/active, which left near-illegible light-green text on
   this button's light-green background. Re-assert the dark text color
   explicitly for every interactive state so it always matches normal. */
.btn--primary:hover,
.btn--primary:focus-visible,
.btn--primary:active {
  background: #33ea90;
  color: var(--color-bg);
}
.btn--lg {
  font-size: var(--text-body);
  padding: 1.25rem 2rem;
}
.btn--outline {
  background: transparent;
  color: var(--color-text);
  border-color: var(--color-border);
  margin-top: var(--space-2);
}
.btn--outline:hover {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 1px rgba(0, 230, 118, 0.15);
}

/* ---------- Text ---------- */
.lede {
  font-size: var(--text-body);
  font-weight: 400;
  line-height: 1.65;
  color: var(--color-text-soft);
  max-width: 65ch;
}
/* TRAMA's card is much wider than the normal reading-column contexts
   .lede's 65ch cap was tuned for (hero, Sobre mí, etc.) — inherited
   from there, it left ~440px of empty space to the right of this short
   intro paragraph before the video below it. */
.timeline__project .lede {
  max-width: 90ch;
}
.quote {
  font-weight: 500;
  font-style: italic;
  font-size: var(--text-quote);
  line-height: 1.65;
  color: var(--color-text);
  max-width: 60ch;
  padding-left: var(--space-3);
  border-left: 2px solid var(--color-accent);
}

/* ---------- Signature motif: thin diagonal mark between sections ---------- */
.signature-line {
  width: 4rem;
  height: 3px;
  margin: 0 auto;
  background: var(--color-accent);
  box-shadow: 0 0 16px rgba(0, 230, 118, 0.55);
  transform: skewX(-24deg);
}

/* Soft closing line before Contacto — a short, large-type transition
   rather than another full section, so it reads as a quiet nudge
   toward the form instead of shouting a second headline. */
.closing-cta {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: var(--text-quote-lg);
  letter-spacing: -0.02em;
  line-height: 1.2;
  text-align: center;
  /* Brand green, not the neutral dark text used elsewhere on this gray
     canvas — --light-accent-ink (not raw --color-accent) since this
     sits directly on the light page background, same reasoning as
     .tone-light's accent-ink swap (raw #00E676 fails contrast there). */
  color: var(--light-accent-ink);
  /* Equal top/bottom margin so this reads as vertically centered in
     the gap between Herramientas and Contacto, not stuck to the top —
     was --space-3 (24px) top / --space-4 (48px) bottom. */
  margin: var(--space-4) auto;
  /* max-width:20ch used to live here to force the deliberate 2-line
     break — but .closing-cta also carries the .wrap class (for the
     shared --wrap-max width every section uses), and this rule's
     max-width:20ch, being a same-specificity single-class selector
     defined after .wrap in the file, was winning the cascade and
     shrinking the whole box to ~600px — misaligning its edges against
     Contacto's full-width card right below it. The line break is now a
     literal <br> in the markup instead, so it's independent of the
     container's width; .wrap's own --wrap-max is what sizes this box,
     same as every other section, and text-align:center still keeps
     the two short lines visually centered inside it. */
}

/* ---------- Specialization cards: uniform grid, click-to-flip ----------
   All 4 items share the exact same width/height/padding — no exceptions,
   so the flip works identically everywhere. Each .specialization__card is
   a real <button> (native Enter/Space + focus, no extra JS needed for
   keyboard) with two absolutely-positioned faces; the button itself gets
   an explicit height since absolutely-positioned children can't size it. */
.specializations {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-3);
}
/* Hover-dim: pure CSS, no JS/position math — hovering the grid dims
   every sibling that ISN'T itself hovered, via :not(:hover). */
.specializations > li {
  transition: opacity 0.25s ease;
}
.specializations:hover > li:not(:hover) {
  opacity: 0.6;
}
@media (min-width: 560px) {
  .specializations { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1000px) {
  .specializations { grid-template-columns: repeat(4, 1fr); }
}
/* --wrap-max grew (point 7, wide-screen breathing room) so these
   columns are proportionally wider on large monitors already (grid
   uses fr units) — bump the card's own internal padding a bit too at
   the widest breakpoint so the icon/label don't look lost in the
   extra width. */
@media (min-width: 1600px) {
  .specialization__face { padding: 2rem; }
}
.specialization { perspective: 1200px; }
.specialization__card {
  position: relative;
  width: 100%;
  height: 210px;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  transform-style: preserve-3d;
  transition: transform 0.55s ease;
}
.specialization__card[aria-pressed="true"] { transform: rotateY(180deg); }
.specialization__face {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  gap: var(--space-2);
  padding: 1.5rem;
  text-align: left;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.specialization__card:hover .specialization__face--front,
.specialization__card:focus-visible .specialization__face--front {
  border-color: var(--color-accent);
}
.specialization__face--back {
  transform: rotateY(180deg);
  background: rgba(0, 230, 118, 0.06);
  justify-content: center;
  /* .specialization__face is a "dark island" (see above) that assumes
     an opaque dark card behind its light text — true for the front
     face, but this back face's own background is a near-transparent
     green tint over the light .tone-light section backdrop, not an
     opaque dark surface. Overriding just this token (rather than the
     background) keeps the intended light-green back-face look while
     fixing --back-text's color to something readable on it: ~5.3:1
     against the composited backdrop, passes WCAG AA. */
  --color-text-soft: var(--light-text-soft);
}
.specialization__icon {
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  background-color: var(--color-accent);
  -webkit-mask-image: var(--icon-url);
  mask-image: var(--icon-url);
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}
.specialization__label {
  font-weight: 600;
  font-size: var(--text-feature);
  color: var(--color-text);
}
.specialization__back-text {
  font-size: var(--text-caption);
  line-height: 1.5;
  color: var(--color-text-soft);
}
.specialization__flip-hint {
  position: absolute;
  top: 1rem;
  right: 1rem;
  width: 16px;
  height: 16px;
  opacity: 0.5;
  background-color: var(--color-text-soft);
  -webkit-mask-image: url('https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/rotate-cw.svg');
  mask-image: url('https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/rotate-cw.svg');
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}
.specialization__sr-status {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  /* The blanket reduced-motion rule earlier in this file forces every
     transition to 0.01ms, which would also kill this crossfade — these
     rules are deliberately scoped more specifically so they win the
     cascade and restore a real (but non-rotating) fade transition. */
  .specialization__card,
  .specialization__card[aria-pressed="true"] {
    transform: none !important;
  }
  .specialization__face {
    transition: opacity 0.25s ease !important;
    transform: none !important;
  }
  .specialization__face--front { opacity: 1; }
  .specialization__face--back { opacity: 0; }
  .specialization__card[aria-pressed="true"] .specialization__face--front { opacity: 0; }
  .specialization__card[aria-pressed="true"] .specialization__face--back { opacity: 1; }
}

/* ---------- Publicaciones recientes (LinkedIn embeds, lazy-loaded) ---------- */
.linkedin-posts__scroller {
  display: flex;
  gap: var(--space-3);
  margin-top: var(--space-3);
  overflow-x: auto;
  scroll-snap-type: x proximity;
  scroll-padding-inline: var(--space-3);
  padding-bottom: var(--space-2); /* room for the scrollbar so it doesn't sit flush against the cards */
}
.linkedin-posts__card {
  flex: 0 0 auto;
  scroll-snap-align: start;
  width: min(400px, 85vw);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-2);
}
.linkedin-posts__date {
  font-size: var(--text-chip);
  color: var(--color-text-soft);
  margin: 0 0 var(--space-1);
}
.linkedin-posts__frame {
  /* Reserves the embed's typical height up front so injecting the
     <iframe> later (once it scrolls into view) doesn't shift layout. */
  min-height: 570px;
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--color-bg);
}
.linkedin-posts__frame iframe {
  width: 100%;
  height: 570px;
  border: none;
  display: block;
}
.linkedin-posts__frame--fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--space-3);
}
.linkedin-posts__frame--fallback a {
  color: var(--color-text-soft);
  font-size: var(--text-caption);
}
.linkedin-posts__frame--fallback a:hover { color: var(--color-accent); }

/* ---------- Intro: Sobre mí + Enfoque merged, asymmetric, unboxed ---------- */
.intro-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-4);
}
@media (min-width: 780px) {
  .intro-grid {
    grid-template-columns: 35fr 65fr;
    gap: var(--space-6);
    align-items: start;
  }
  /* Sticky only makes sense once the quote and text sit in separate
     columns — on a single mobile column it would pin the quote in place
     and let the text scroll underneath it, overlapping both. */
  .intro-visual {
    position: sticky;
    top: 100px;
  }
}
.intro-quote {
  font-family: var(--font-body);
  font-size: var(--text-quote-lg);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.15;
  margin: 0;
  color: var(--color-text);
}
.stat-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  margin: var(--space-4) 0 0;
}
.stat { margin: 0; }
.stat__value {
  display: block;
  margin: 0;
  font-size: var(--text-h2);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--color-accent);
}
.stat__label {
  font-size: var(--text-caption);
  color: var(--color-text-soft);
}
.intro-text h2 {
  scroll-margin-top: 84px;
}
@media (min-width: 780px) {
  /* The grid's two columns are top-aligned (align-items:start), but that
     lines "Sobre mí" up with the tiny .eyebrow label instead of the
     large pull-quote next to it — the quote is the visually dominant
     element in that column, so the heading read as floating too high.
     Nudge it down by exactly the eyebrow's rendered height (line-height
     × font-size) plus its own margin-bottom, so "Sobre mí" starts at
     the same height as the quote text itself. */
  .intro-text h2:first-of-type {
    margin-top: calc(var(--text-eyebrow) * 1.65 + var(--space-2));
  }
}
.intro-text h2 + .lede {
  margin-bottom: var(--space-4);
}

/* ---------- Perspectivas ---------- */
.perspectives-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-4);
  margin-top: var(--space-3);
}
@media (min-width: 700px) {
  .perspectives-grid { grid-template-columns: repeat(3, 1fr); }
}
.perspective-item h3 {
  font-size: var(--text-feature);
  font-weight: 700;
  margin-bottom: var(--space-1);
}
.perspective-item p {
  margin: 0;
  color: var(--color-text-soft);
}

/* ---------- Case block (TRAMA) ---------- */
.case__context {
  font-size: var(--text-caption);
  color: var(--color-text-soft);
  margin-top: calc(var(--space-2) * -0.5);
  margin-bottom: var(--space-3);
}
.case__note {
  font-size: var(--text-caption);
  color: var(--color-text-soft);
  margin-top: var(--space-1);
}

/* ---------- TRAMA media: decorative sphere (replaces the old demo video) ---------- */
.trama-sphere {
  margin: var(--space-3) 0;
}
.trama-sphere__filters {
  display: flex;
  gap: 0.6rem;
  margin-bottom: var(--space-2);
}
.trama-sphere__dropdown {
  position: relative;
}
.trama-sphere__dropdown-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--text-chip);
  font-weight: 500;
  font-family: var(--font-body);
  padding: 0.4rem 0.85rem;
  border-radius: 999px;
  background: rgba(0, 230, 118, 0.06);
  border: 1px solid var(--color-border);
  color: var(--color-text-soft);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease;
}
.trama-sphere__dropdown-toggle:hover { border-color: var(--color-accent); }
.trama-sphere__dropdown-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.trama-sphere__dropdown-arrow {
  width: 7px;
  height: 7px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg) translateY(-1px);
  transition: transform 0.15s ease;
}
.trama-sphere__dropdown-toggle[aria-expanded="true"] .trama-sphere__dropdown-arrow {
  transform: rotate(-135deg) translateY(1px);
}
/* Active state: this group has at least one filter selected — set via
   JS alongside aria-pressed on its items, since the toggle button
   itself needs to stay legible as "something is filtered" even while
   its menu is closed. */
.trama-sphere__dropdown-toggle.has-active {
  background: rgba(0, 230, 118, 0.16);
  border-color: var(--color-accent);
  color: var(--color-accent);
  font-weight: 700;
}
.trama-sphere__dropdown-menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 5;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 170px;
  padding: 0.4rem;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 10px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
/* The [hidden] attribute's own display:none (set by JS to open/close
   the menu) was losing to the display:flex above — same-origin class
   selector beats the UA stylesheet's [hidden] rule, so the menu was
   rendering open by default regardless of the hidden property. */
.trama-sphere__dropdown-menu[hidden] {
  display: none;
}
.trama-sphere__dropdown-item {
  display: flex;
  align-items: center;
  width: 100%;
  text-align: left;
  font-size: var(--text-chip);
  font-weight: 500;
  font-family: var(--font-body);
  padding: 0.45rem 0.6rem;
  border-radius: 6px;
  background: none;
  border: 1px solid transparent;
  color: var(--color-text-soft);
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease;
}
.trama-sphere__dropdown-item:hover { background: rgba(0, 230, 118, 0.08); }
.trama-sphere__dropdown-item:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }
.trama-sphere__dropdown-item[aria-pressed="true"] {
  background: rgba(0, 230, 118, 0.14);
  color: var(--color-accent);
  font-weight: 700;
}
.trama-sphere__dropdown-item[aria-pressed="true"]::after {
  content: '✓';
  margin-left: auto;
  padding-left: 0.6rem;
}

.trama-media {
  border-radius: var(--radius-lg);
  overflow: hidden;
  border: 1px solid var(--color-border);
  background: var(--color-bg);
}
.trama-sphere__stage {
  position: relative;
  width: 100%;
  aspect-ratio: 1280 / 580; /* shorter than the old 1280:684 — a bit less vertical space */
}
.trama-sphere__canvas {
  display: block;
  width: 100%;
  height: 100%;
  cursor: grab;
  will-change: transform;
  touch-action: none;
}
.trama-sphere__canvas.is-dragging { cursor: grabbing; }
.trama-sphere__tooltip {
  position: absolute;
  top: 0;
  left: 0;
  padding: 5px 10px;
  background: rgba(5, 16, 12, 0.94);
  border: 1px solid rgba(0, 230, 118, 0.4);
  border-radius: 6px;
  color: #fff;
  font-size: 12.5px;
  font-weight: 600;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  will-change: transform, opacity;
  transition: opacity 0.1s ease;
  z-index: 2;
}
.trama-sphere__tooltip.is-visible { opacity: 1; }
/* ---------- TRAMA validation block: real prediction-vs-reality data ----------
   Every animated element here (.trama-precision__bar-fill/__bar-tick
   position, .trama-invest line draw, the .stat__value counters) uses
   the SAME IntersectionObserver-driven, fire-once pattern already used
   for the "Años de experiencia" stat counters in Sobre mí (see
   script.js) — no new scroll/mousemove listener, no continuous/looping
   animation. */
.trama-validation {
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  border-top: 1px solid var(--color-border);
}
/* Block heading + score badge, plain flowing text ABOVE the 2-column
   row — not overlaid on the poster image anymore (see .trama-title-
   card below: it went back to a clean, uncropped image with zero text
   on top of it). */
.trama-validation__header {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75em;
  margin-bottom: var(--space-3);
}
.trama-validation__title {
  margin: 0;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 26px;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--color-text);
}
.trama-validation__badge {
  display: inline-block;
  padding: 0.3em 0.8em;
  border-radius: 999px;
  background: rgba(0, 230, 118, 0.12);
  border: 1px solid rgba(0, 230, 118, 0.3);
  color: var(--color-accent);
  font-size: var(--text-chip);
  font-weight: 700;
}

/* Left column (poster + stats + tech stack, one uniform-gap stack) |
   vertical divider | right column (precision row + line chart) —
   collapses to a single stacked column on mobile (divider hidden).
   align-items:stretch lets the divider span whichever column ends up
   taller. */
.trama-validation__row {
  display: grid;
  grid-template-columns: 412px 1px 1fr;
  column-gap: 32px;
  align-items: stretch;
}
@media (max-width: 720px) {
  .trama-validation__row { grid-template-columns: 1fr; }
  .trama-validation__divider { display: none; }
}
.trama-validation__divider {
  background: var(--color-border);
}
.trama-validation__right {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
/* Single gap value for every stacked piece — poster, stats, tech
   stack — instead of the mismatched per-element margins that had
   accumulated across rounds. Only 3 items now; the CTA and the
   sourcing note both moved out (see below / the right column). */
.trama-validation__left {
  display: flex;
  flex-direction: column;
  gap: 20px;
  min-width: 0;
}
@media (max-width: 720px) {
  .trama-validation__left { margin-bottom: var(--space-4); }
}

/* Full width below the whole 2-column row (not squeezed into either
   column) — left-aligned, matching the rest of the block's flow. */
.trama-validation__cta {
  margin-top: var(--space-4);
}

/* Title card: AI-generated poster image, shown clean and uncropped —
   object-fit:contain plus an aspect-ratio matching the source file's
   own 9:16 proportions means nothing is cut off and nothing letterboxes
   either. Border + soft glow borrow the hero photo's accent-green
   treatment to tie this image to the rest of the brand — static, not
   the hero's looping pulse, since this element normally starts off-
   screen inside a collapsed timeline entry and a second perpetual
   animation isn't worth it for a detail this small. */
.trama-title-card {
  width: 412px;
  aspect-ratio: 9 / 16;
  border-radius: 12px;
  border: 1px solid rgba(0, 230, 118, 0.35);
  box-shadow: 0 0 28px 2px rgba(0, 230, 118, 0.14);
  background: #0D2A1C;
  overflow: hidden;
}
@media (max-width: 720px) {
  .trama-title-card { width: 100%; }
}
.trama-title-card__image {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

/* Precision chart: white progress bar (real audience) up to its own
   value, with a thin accent-green tick marking the TRAMA prediction
   near its end — the gap between fill-end and tick IS the margin of
   error. Bar and diff figure share one row instead of stacking.
   --bar-max is a JS-readable custom property (script.js reads target/
   max*100% off it) shared by both [data-bar-fill] and [data-bar-pos]
   elements. White + accent green only — no amber. */
.trama-precision__row {
  display: flex;
  align-items: center;
  gap: 20px;
  margin: var(--space-4) 0 var(--space-3);
}
.trama-precision__bar-track {
  position: relative;
  flex: 0 1 640px;
  width: 640px;
  max-width: 100%;
  height: 14px;
  border-radius: 999px;
  background: rgba(242, 245, 243, 0.1);
  overflow: visible;
}
.trama-precision__bar-fill {
  position: absolute;
  inset: 0;
  width: 0%;
  border-radius: 999px;
  background: #FFFFFF;
  transition: width 1.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.trama-precision__bar-tick {
  position: absolute;
  top: -5px;
  bottom: -5px;
  left: 0%;
  width: 3px;
  margin-left: -1.5px;
  border-radius: 2px;
  background: var(--color-accent);
  opacity: 0;
  transition: left 1.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease 0.9s;
}
.trama-precision__diff {
  display: flex;
  align-items: baseline;
  gap: 0.3em;
  margin: 0;
  flex-shrink: 0;
  white-space: nowrap;
}
.trama-precision__diff-value {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 2rem;
  font-weight: 700;
  color: var(--color-accent);
  line-height: 1;
}
.trama-precision__diff-label {
  font-size: var(--text-caption);
  color: var(--color-text-soft);
}
.trama-precision__values {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border);
}
.trama-precision__value {
  display: flex;
  align-items: center;
  gap: 0.5em;
}
.trama-precision__value-label {
  font-size: var(--text-caption);
  color: var(--color-text-soft);
}
.trama-precision__value-number {
  margin-left: 0.15em;
  font-size: 1.25rem;
  font-weight: 700;
  color: var(--color-text);
}
.trama-precision__swatch--real {
  width: 10px;
  height: 10px;
  border-radius: 3px;
  background: #FFFFFF;
  flex-shrink: 0;
}
.trama-precision__swatch--prediction {
  width: 14px;
  height: 2px;
  background: var(--color-accent);
  flex-shrink: 0;
}

/* Investment vs. estimated-risk-avoided line chart — full width, tall
   enough to have real presence, no background gridlines (only the
   2 data lines + Y-axis text carry the scale). Each data point marked
   with a dot on both lines. */
.trama-invest {
  margin-top: 16px;
}
.trama-invest__title {
  margin: 0 0 12px;
  font-size: var(--text-caption);
  font-weight: 600;
  color: var(--color-text-soft);
}
.trama-invest__chart {
  height: 390px;
  display: flex;
  align-items: center;
}
/* viewBox (655:390) rebuilt AGAIN to match this container's real
   proportions after the title card grew to 412px and ate into the
   right column's width (~792px → ~655px at a common desktop width) —
   every layout change that resizes this container needs the viewBox
   recalculated the same way, or "meet" either letterboxes badly or (if
   preserveAspectRatio were ever "none" again) distorts every shape.
   preserveAspectRatio="xMinYMid" (not xMidYMid) anchors content to the
   LEFT edge of the viewBox instead of centering it, so any leftover
   space from "meet" letterboxing collects on the right only — combined
   with the Y-axis labels themselves starting at viewBox x=0 (text-
   anchor:start, not the old right-anchored "end"), the chart's leftmost
   rendered pixel lines up with .trama-invest__title's own left edge,
   not offset by half the letterbox gap. */
.trama-invest__svg {
  display: block;
  width: 100%;
  height: 390px;
  overflow: visible;
}
.trama-invest__axis-labels text {
  fill: var(--color-text-soft);
  font-size: 11px;
  font-family: var(--font-body);
  text-anchor: start;
  dominant-baseline: middle;
}
.trama-invest__cat-labels text {
  fill: var(--color-text-soft);
  font-size: 11px;
  font-family: var(--font-body);
  font-weight: 600;
  text-anchor: middle;
}
.trama-invest__points circle {
  stroke: none;
}
.trama-invest__points--budget circle { fill: var(--color-text-soft); }
.trama-invest__points--risk circle { fill: var(--color-accent); }
.trama-invest__area {
  fill: rgba(0, 230, 118, 0.12);
  stroke: none;
  opacity: 0;
  transition: opacity 0.6s ease 1s;
}
.trama-invest__line {
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.trama-invest__line--budget {
  stroke: var(--color-text-soft);
  stroke-width: 2;
  stroke-dasharray: 5 5;
}
.trama-invest__line--risk {
  stroke: var(--color-accent);
  stroke-width: 3;
}
.trama-invest__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-4);
  margin-top: 10px;
  font-size: var(--text-caption);
  color: var(--color-text-soft);
}
.trama-invest__legend-item {
  display: flex;
  align-items: center;
  gap: 0.5em;
}
.trama-invest__swatch {
  width: 16px;
  height: 2px;
  flex-shrink: 0;
}
.trama-invest__swatch--budget { background: repeating-linear-gradient(90deg, var(--color-text-soft) 0 4px, transparent 4px 7px); }
.trama-invest__swatch--risk { background: var(--color-accent); }
.trama-invest__disclaimer {
  margin: var(--space-2) 0 0;
  font-size: var(--text-chip);
  color: var(--color-text-soft);
  max-width: 60ch;
}

/* Compact list under the title card, not the full-width/large-type
   band this used to be — each stat is a single row (value + label
   side by side) so 3 of them fit comfortably in the 412px column. */
.trama-validation__stats {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 0;
}
.trama-validation__stats .stat {
  display: flex;
  align-items: baseline;
  gap: 0.6em;
}
.trama-validation__stats .stat__value {
  font-size: 1.3rem; /* ~65% of the old var(--text-h2) / 2rem */
  flex-shrink: 0;
}
.trama-validation__stats .stat__label {
  color: var(--color-text-soft);
}

.chip-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: var(--space-3) 0;
}
/* Hover-dim: same pure-CSS pattern as .specializations above. Targets
   `> li` directly since chip-list children are <li> either way, whether
   they carry the .chip-item tooltip-wrapper class (Herramientas) or are
   a plain .chip (TRAMA's stack list). */
.chip-list > li {
  transition: opacity 0.25s ease;
}
.chip-list:hover > li:not(:hover) {
  opacity: 0.6;
}
.chip-item { position: relative; }
.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-size: var(--text-chip);
  font-weight: 500;
  font-family: var(--font-body);
  padding: 0.5rem 1rem;
  border-radius: 999px;
  background: rgba(0, 230, 118, 0.06);
  border: 1px solid var(--color-border);
  color: var(--color-text);
  cursor: pointer;
  transition: border-color 0.2s ease;
}
.chip:hover,
.chip[aria-expanded="true"] {
  border-color: var(--color-accent);
}
@media (prefers-reduced-motion: reduce) {
  .chip { transition: none; }
}
.chip-tooltip {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  z-index: 30;
  width: max-content;
  max-width: 240px;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  padding: 0.75rem 1rem;
  font-size: var(--text-chip);
  line-height: 1.5;
  color: var(--color-text-soft);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.3);
  opacity: 0;
  transform: translateY(-4px);
  pointer-events: none;
  transition: opacity 0.15s ease, transform 0.15s ease;
}
.chip-tooltip.is-open {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
  .chip-tooltip { transition: none; }
}
.chip__icon {
  width: 16px;
  height: 16px;
  flex-shrink: 0;
  background-color: var(--color-text-soft);
  -webkit-mask-image: var(--icon-url);
  mask-image: var(--icon-url);
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}

/* ---------- Timeline (shared by Experiencia and Formación) ---------- */
.timeline {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  margin-top: var(--space-3);
  /* Full section width (--wrap-max, via the parent .wrap) rather than
     the old --wrap-narrow reading-column treatment — Experiencia and
     Formación should read at the same width as every other section. */
  padding-left: 56px;
}
.timeline::before {
  content: "";
  position: absolute;
  left: 18px;
  top: 6px;
  bottom: 6px;
  width: 1px;
  background: var(--color-border);
}
.timeline__item { position: relative; }
.timeline__item.item-init {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.timeline__item.item-init.is-visible {
  opacity: 1;
  transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
  .timeline__item.item-init { transition: none; }
}
.timeline__year {
  display: block;
  font-weight: 600;
  color: var(--color-accent-ink);
  font-size: var(--text-caption);
  margin-bottom: 0.2em;
}
.timeline__item h3 { margin-bottom: 0.2em; }
.timeline__org {
  font-weight: 600;
  color: var(--color-text-soft);
  margin: 0;
  font-size: var(--text-caption);
}
/* Text-based wordmark stand-in for an institution logo — used for ECAM
   until a real logo file is available (drop one in assets/ and swap
   this span for an <img> when it exists). Styled distinctly from plain
   .timeline__org text (bordered badge, letter-spaced caps) so it reads
   as a logotype rather than a label. */
.org-logo {
  display: inline-block;
  font-family: var(--font-body);
  font-weight: 700;
  font-size: var(--text-chip);
  letter-spacing: 0.08em;
  color: var(--color-accent-ink);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  padding: 0.2em 0.6em;
}
.timeline__desc { margin: 0; color: var(--color-text-soft); }

/* Marker: briefcase (work) or graduation-cap (education) — same neutral
   circle style for both, the icon shape itself is the type indicator so
   the page doesn't need a second accent color. */
.timeline__marker {
  position: absolute;
  left: -56px;
  top: 0;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  flex-shrink: 0;
}
.timeline__body { width: 100%; }
.timeline__summary-text {
  display: flex;
  flex-direction: column;
  gap: 0.2em;
}
.timeline__marker-icon {
  width: 16px;
  height: 16px;
  background-color: var(--color-accent);
  -webkit-mask-image: var(--icon-url);
  mask-image: var(--icon-url);
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}

/* Collapsible summary/detail: a real <button> so Enter/Space and focus
   work natively. The detail panel is always in the DOM (no `hidden`
   attribute) so it can animate via max-height; aria-hidden keeps it out
   of the accessibility tree while collapsed. */
.timeline__summary {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-2);
  width: 100%;
  padding: 0;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.timeline__chevron {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  margin-top: 0.3em;
  background-color: var(--color-text-soft);
  -webkit-mask-image: url('https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/chevron-down.svg');
  mask-image: url('https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/chevron-down.svg');
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  transition: transform 0.3s ease, background-color 0.2s ease;
}
.timeline__summary:hover .timeline__chevron,
.timeline__summary:focus-visible .timeline__chevron {
  background-color: var(--color-accent);
}
.timeline__summary[aria-expanded="true"] .timeline__chevron {
  transform: rotate(180deg);
  background-color: var(--color-accent);
}
.timeline__detail {
  max-height: 0;
  opacity: 0;
  overflow: hidden;
  transition: max-height 0.4s ease, opacity 0.3s ease;
}
.timeline__detail-inner { padding-top: var(--space-3); }
.timeline__summary[aria-expanded="true"] + .timeline__detail {
  opacity: 1;
  /* max-height is set inline in px by script.js (to the panel's real
     content height) when expanded, since a CSS max-height transition
     needs a concrete end value, not "auto". */
}
@media (prefers-reduced-motion: reduce) {
  .timeline__detail { transition: none; }
}

/* ---------- TRAMA project, nested inside the DataQuantumX timeline entry ---------- */
.timeline__project {
  margin-top: var(--space-3);
  padding: 1.5rem 1.75rem;
  background: #152922;
  border-left: 2px solid var(--color-accent);
  border-radius: var(--radius-sm);
  scroll-margin-top: 84px;
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.timeline__project:hover,
.timeline__project:focus-within {
  transform: translateY(-2px);
}
@media (prefers-reduced-motion: reduce) {
  .timeline__project { transition: none; }
}
@media (max-width: 600px) {
  .timeline__project { padding: 1.25rem; }
}

/* Narrower marker gutter on small phones — the desktop 56px gutter plus
   .wrap's own side padding was squeezing timeline body content (incl.
   the TRAMA block) into under half the viewport width. */
@media (max-width: 600px) {
  .timeline { padding-left: 40px; }
  .timeline::before { left: 12px; }
  .timeline__marker { left: -40px; width: 28px; height: 28px; }
  .timeline__marker-icon { width: 13px; height: 13px; }
}

/* ---------- Tools ---------- */
.tools-grid {
  display: flex;
  flex-direction: column;
  gap: var(--space-5) var(--space-6);
  align-items: stretch;
  margin-top: var(--space-3);
}
/* Equal-thirds columns left a big dead zone next to categories with far
   fewer chips (e.g. "Análisis y automatización" at 2 vs 7). flex-grow
   weighted by each group's own chip count (--chip-count, set inline per
   group) gives every column just the width its content actually needs,
   instead of an even split. */
.tools-group {
  flex: var(--chip-count, 1) 1 0;
  min-width: 200px;
}
/* Below this, 3 columns at their 200px floor don't all fit on one line —
   flex-wrap would then drop just the last group onto its own row,
   leaving it stranded alone with the same empty-space problem this is
   meant to fix. Single-column stack instead, same as mobile, until
   there's genuinely enough room for all 3 side by side (tested at the
   actual chip counts: fits from ~1005px card content width up). */
@media (min-width: 1010px) {
  .tools-grid { flex-direction: row; }
}
.tools-group h3 {
  font-size: var(--text-eyebrow);
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--color-accent);
  margin-bottom: var(--space-2);
}

/* ---------- Contact ---------- */
.contact-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-4);
  margin-top: var(--space-3);
}
@media (min-width: 700px) {
  .contact-grid { grid-template-columns: 1.3fr 1fr; }
}
.contact-form {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}
.contact-aside {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  gap: var(--space-3);
  padding: 2rem;
  text-align: center;
  background: rgba(0, 230, 118, 0.04);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
}
.contact-aside__avatar {
  position: relative;
  width: 64px;
  height: 64px;
}
.contact-aside__avatar img {
  width: 100%;
  height: 100%;
  border-radius: 50%;
  object-fit: cover;
  object-position: center 22%;
  border: 2px solid var(--color-border);
}
.contact-aside__badge {
  position: absolute;
  bottom: -2px;
  right: -2px;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--color-accent);
  border: 2px solid var(--color-card);
}
.contact-aside__badge-icon {
  width: 12px;
  height: 12px;
  background-color: var(--color-bg);
  -webkit-mask-image: var(--icon-url);
  mask-image: var(--icon-url);
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}
.contact-aside__text {
  margin: 0;
  color: var(--color-text-soft);
  font-size: var(--text-body);
}
.contact-aside__actions {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
}

.copy-email-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  background: none;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 0.6rem 1.25rem;
  color: var(--color-text-soft);
  font-size: var(--text-caption);
  font-weight: 500;
  font-family: var(--font-body);
  cursor: pointer;
  transition: border-color 0.2s ease, color 0.2s ease;
}
.copy-email-btn:hover,
.copy-email-btn:focus-visible {
  border-color: var(--color-accent);
  color: var(--color-text);
}
.copy-email-btn.is-copied {
  border-color: var(--color-accent);
  color: var(--color-accent);
}
.copy-email-btn__icon {
  width: 15px;
  height: 15px;
  flex-shrink: 0;
  background-color: currentColor;
  -webkit-mask-image: url('https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/mail.svg');
  mask-image: url('https://cdn.jsdelivr.net/npm/lucide-static@latest/icons/mail.svg');
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}
.hidden-field { position: absolute; left: -9999px; }
.field { display: flex; flex-direction: column; gap: 0.5rem; }
.field label {
  font-size: var(--text-caption);
  font-weight: 600;
  color: var(--color-text);
}
.field input,
.field textarea {
  font-family: var(--font-body);
  font-size: var(--text-body);
  padding: 1rem;
  border: 1px solid var(--color-border);
  border-radius: var(--radius);
  background: var(--color-bg);
  color: var(--color-text);
  resize: vertical;
}
.field input::placeholder,
.field textarea::placeholder { color: var(--color-text-soft); }
.field input:focus,
.field textarea:focus { border-color: var(--color-accent); }

.form-status {
  font-size: var(--text-caption);
  color: var(--color-accent);
  min-height: 1.2em;
  margin: 0;
}

/* ---------- Footer ---------- */
.footer {
  position: relative;
  padding-block: var(--section-pad-y);
}
@media (min-width: 900px) {
  .footer { padding-block: var(--section-pad-y-lg); }
}
.footer__grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--space-2);
  font-size: var(--text-caption);
  color: var(--color-text-soft);
}
.footer a { text-decoration: none; font-weight: 600; }
.footer a:hover { text-decoration: underline; }

/* ---------- Scroll progress bar ---------- */
.scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 0%;
  background: var(--color-accent);
  z-index: 70;
  pointer-events: none;
}

/* ---------- Vertical side-rail (desktop-wide only) ----------
   A fixed-height progress rail, independent of the horizontal top nav.
   The fill height is driven by overall page scroll % (script.js), the
   5 nodes are evenly spaced (flex space-between) rather than mapped to
   literal section positions — same simplification the horizontal
   .scroll-progress bar already uses. */
.side-rail {
  display: none;
}
@media (min-width: 1200px) {
  .side-rail {
    display: block;
    position: fixed;
    left: 40px;
    top: 50%;
    transform: translateY(-50%);
    width: 24px;
    height: min(52vh, 440px);
    z-index: 50;
  }
}
.side-rail__track {
  position: absolute;
  left: 11px;
  top: 0;
  bottom: 0;
  width: 1px;
  background: var(--color-border);
}
.side-rail__fill {
  position: absolute;
  left: 11px;
  top: 0;
  width: 1px;
  height: 0%;
  background: var(--color-accent);
  transition: height 0.2s linear;
}
@media (prefers-reduced-motion: reduce) {
  .side-rail__fill { transition: none; }
}
.side-rail__nodes {
  position: relative;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
.side-rail__node {
  position: relative;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
}
.side-rail__node::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--color-text-soft);
  transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.side-rail__node:hover::before,
.side-rail__node:focus-visible::before {
  transform: scale(1.4);
}
.side-rail__node.is-active::before {
  background: var(--color-accent);
  transform: scale(1.6);
  box-shadow: 0 0 0 4px rgba(0, 230, 118, 0.18);
}
@media (prefers-reduced-motion: reduce) {
  .side-rail__node::before { transition: none; }
}
.side-rail__tooltip {
  position: absolute;
  left: 32px;
  top: 50%;
  transform: translateY(-50%) translateX(-4px);
  white-space: nowrap;
  font-size: var(--text-chip);
  font-weight: 500;
  color: var(--color-text);
  background: var(--color-card);
  border: 1px solid var(--color-border);
  padding: 0.35em 0.85em;
  border-radius: 6px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease, transform 0.15s ease;
}
.side-rail__node:hover .side-rail__tooltip,
.side-rail__node:focus-visible .side-rail__tooltip {
  opacity: 1;
  transform: translateY(-50%) translateX(0);
}
@media (prefers-reduced-motion: reduce) {
  .side-rail__tooltip { transition: none; }
}

/* ---------- Floating nav ---------- */
.site-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 60;
  border-bottom: 1px solid transparent;
  opacity: 0;
  transform: translateY(-8px);
  pointer-events: none;
  transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
}
.site-nav.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
  background: rgba(16, 32, 28, 0.86);
  border-bottom-color: var(--color-border);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
}
.site-nav__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-block: 1rem;
}
.site-nav__brand {
  font-weight: 600;
  font-size: var(--text-chip);
  color: var(--color-text);
  text-decoration: none;
}
.site-nav__links {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.site-nav__links a {
  font-size: var(--text-chip);
  font-weight: 500;
  color: var(--color-text-soft);
  text-decoration: none;
  transition: color 0.15s ease;
}
.site-nav__links a:hover,
.site-nav__links a.is-active {
  color: var(--color-accent);
}
.site-nav__toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 4px;
  width: 36px;
  height: 36px;
  background: transparent;
  border: 1px solid var(--color-border);
  border-radius: 8px;
  cursor: pointer;
  padding: 0;
}
.site-nav__toggle-bar {
  width: 16px;
  height: 2px;
  background: var(--color-text);
  border-radius: 1px;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar:nth-child(2) { opacity: 0; }
.site-nav__toggle[aria-expanded="true"] .site-nav__toggle-bar:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

@media (max-width: 780px) {
  .site-nav__toggle { display: flex; }
  .site-nav__links {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-2);
    background: rgba(16, 32, 28, 0.97);
    border-bottom: 1px solid var(--color-border);
    padding: var(--space-2) var(--space-3) var(--space-3);
    display: none;
  }
  .site-nav__links.is-open { display: flex; }
}

/* ---------- Back to top ---------- */
.back-to-top {
  position: fixed;
  right: 1.5rem;
  bottom: 1.5rem;
  z-index: 40;
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  color: var(--color-text);
  cursor: pointer;
  opacity: 0;
  transform: translateY(8px);
  pointer-events: none;
  transition: opacity 0.25s ease, transform 0.25s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.back-to-top.is-visible {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}
.back-to-top:hover,
.back-to-top:focus-visible {
  border-color: var(--color-accent);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28), 0 0 0 1px rgba(0, 230, 118, 0.2);
  transform: translateY(-2px);
}

/* Pause the perpetual halo animation while the page is actually
   scrolling — see the `is-scrolling` class toggle in script.js for the
   full rationale (real-device trace showed constant background
   paint/render cost, not a one-off spike). The rays/ambient-glow rules
   that used to live here were removed along with those elements
   themselves (replaced by a static gradient background). */
body.is-scrolling .hero__photo img {
  animation-play-state: paused;
}

/* ============================================================
   TEMPORARY — scroll-smoothness bisection toggles, ?debug=perf
   Not a fix. Lets a real device isolate which continuous visual
   effect is costing GPU/compositor time during scroll (headless
   sandbox testing has no GPU, so it can't measure this). Each
   class is toggled live by the panel injected in script.js.
   Delete this whole block + the matching JS block once the real
   culprit is found and a permanent fix is chosen instead.
   ============================================================ */
body.debug-no-halo .hero__photo img { animation: none !important; }
body.debug-no-navblur .site-nav.is-visible {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
}
#perf-debug-panel * { box-sizing: border-box; }
#perf-debug-panel label:hover { color: var(--color-accent); }
