/* Axel Rosso — personal site
   Two themes, one accent, used sparingly.

   Theming rule: every colour is a token on :root (dark) and overridden inside
   [data-theme="light"]. Nothing hardcodes a hex outside this block, so a new
   theme is one more override and never a hunt through the file.

   The accent splits in two on purpose. --accent is the fill: the lime stays
   lime in both themes because it is the brand. --accent-ink is the same idea
   as text, and lime text on white is unreadable, so light mode darkens it. */

:root {
  --bg: #0a0908;
  --bg-alt: #0f0d0b;
  --surface: #141210;
  --surface-hi: #1a1714;
  --line: rgba(255, 255, 255, 0.08);
  --line-hi: rgba(255, 255, 255, 0.16);
  --text: #f5f2ee;
  --text-mid: #b9b3ab;
  --muted: #8f887f;

  --accent: #c6f24e;
  --accent-ink: #c6f24e;
  --on-accent: #08090b;
  --accent-soft: rgba(198, 242, 78, 0.12);
  --accent-line: rgba(198, 242, 78, 0.3);
  --warm: #ff9d5c;
  --warm-soft: rgba(255, 157, 92, 0.1);

  --nav-bg: rgba(8, 9, 11, 0.72);
  --hover-wash: rgba(255, 255, 255, 0.05);
  --spot: rgba(198, 242, 78, 0.11);
  --grain: 0.035;

  /* heatmap ramp */
  --lv1: rgba(198, 242, 78, 0.34);
  --lv2: rgba(198, 242, 78, 0.56);
  --lv3: rgba(198, 242, 78, 0.78);
  --lv4: #c6f24e;

  /* page-wide gradient wash, moved by the parallax layer */
  --wash-1: rgba(255, 157, 92, 0.12);
  --wash-2: rgba(198, 242, 78, 0.09);
  --grid-ink: rgba(255, 255, 255, 1);
  --grid-opacity: 0.07;
  --margen: rgba(255, 157, 92, 0.55);
  --margen-op: 0.3;
  --orb-1: rgba(255, 157, 92, 0.1);
  --orb-2: rgba(198, 242, 78, 0.085);
  --orb-3: rgba(255, 200, 120, 0.065);
  --glow-1: rgba(255, 157, 92, 0.24);
  --glow-2: rgba(198, 242, 78, 0.19);
  --glow-3: rgba(255, 200, 120, 0.13);

  /* mascots sit behind the cards; dark mode pushes them down into the page */
  --mascot-opacity: 0.4;
  --mascot-filter: invert(1);

  --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --wrap: 1080px;
  --pad: clamp(20px, 5vw, 40px);
  --radius: 14px;

  --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
  color-scheme: dark;
}

[data-theme="light"] {
  --bg: #fbfaf7;
  --bg-alt: #f3f0ea;
  --surface: #ffffff;
  --surface-hi: #f7f5f0;
  --line: rgba(24, 22, 16, 0.11);
  --line-hi: rgba(24, 22, 16, 0.22);
  --text: #17150f;
  --text-mid: #4d4840;
  --muted: #6f6859;

  --accent: #c6f24e;
  --accent-ink: #5c8407;
  --on-accent: #08090b;
  --accent-soft: rgba(150, 200, 30, 0.16);
  --accent-line: rgba(120, 165, 20, 0.42);
  --warm: #c2611f;
  --warm-soft: rgba(255, 157, 92, 0.16);

  --nav-bg: rgba(251, 250, 247, 0.78);
  --hover-wash: rgba(24, 22, 16, 0.045);
  --spot: rgba(150, 200, 30, 0.16);
  --grain: 0.02;

  --lv1: #e4f2b8;
  --lv2: #c8e478;
  --lv3: #a3cd3a;
  --lv4: #7ba715;

  --wash-1: rgba(255, 157, 92, 0.2);
  --wash-2: rgba(160, 210, 40, 0.17);
  --grid-ink: rgba(70, 110, 190, 1);
  --grid-opacity: 0.16;
  --margen: rgba(200, 70, 60, 1);
  --margen-op: 0.34;
  --orb-1: rgba(255, 157, 92, 0.17);
  --orb-2: rgba(150, 200, 30, 0.145);
  --orb-3: rgba(255, 190, 110, 0.125);
  --glow-1: rgba(255, 157, 92, 0.32);
  --glow-2: rgba(170, 220, 50, 0.28);
  --glow-3: rgba(255, 200, 120, 0.22);

  /* on a light page the same figures have to be lifted, not darkened */
  --mascot-opacity: 0.34;
  --mascot-filter: none;

  color-scheme: light;
}

*, *::before, *::after { box-sizing: border-box; }

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 17px;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  transition: background-color 0.35s ease, color 0.35s ease;
}

/* ---------- background layers ---------- */

/* Five fixed layers, each drifting at its own rate against the scroll, which
   is what turns a flat gradient into depth: the grid goes one way, the wash
   and the orbs go the other and at different speeds. Every layer is
   oversized and every drift is capped in app.js, so none can expose an edge
   however long the page gets. */
.bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.bg > * {
  position: absolute; display: block;
  will-change: transform;
  transition: opacity 0.35s ease;
}

.bg__wash {
  inset: -25% -10%;
  background:
    radial-gradient(58% 38% at 14% 12%, var(--wash-1), transparent 62%),
    radial-gradient(48% 34% at 96% 34%, var(--wash-2), transparent 66%),
    radial-gradient(40% 30% at 40% 88%, var(--wash-1), transparent 68%);
  transform: translate3d(0, var(--y-wash, 0px), 0);
}

/* El renglón de cuaderno. Es la capa que antes era una cuadrícula: se le
   cambió el dibujo en vez de sumar una encima, porque dos tramas superpuestas
   se ven sucias y porque app.js ya le escribe el parallax a esta.

   🔴 SOLO HORIZONTALES, SIN VERTICALES. Un cuadriculado se lee como papel
   milimetrado o como una grilla de diseño; el renglón se lee como un cuaderno.
   Es la misma capa y una línea menos de CSS, y cambia por completo qué objeto
   parece la página.

   ⚠️ Y EL PASO ES 32px, QUE NO ES ARBITRARIO: es el interlineado del cuerpo de
   texto. Con cualquier otro número los renglones cruzan los párrafos en
   diagonal visual, cada línea de texto cae en un lugar distinto del renglón, y
   el ojo lo lee como desalineado sin saber por qué. Cuadrando con el
   interlineado, el texto se apoya en el renglón como en un cuaderno de verdad.
   Si algún día cambia `line-height` del cuerpo, este número cambia con él. */
.bg__grid {
  inset: -30% -10%;
  opacity: var(--grid-opacity);
  background-image: linear-gradient(to bottom, var(--grid-ink) 1px, transparent 1px);
  background-size: 100% 32px;
  -webkit-mask-image: radial-gradient(72% 62% at 50% 42%, #000 0%, transparent 82%);
  mask-image: radial-gradient(72% 62% at 50% 42%, #000 0%, transparent 82%);
  transform: translate3d(0, var(--y-grid, 0px), 0);
}

/* El margen del cuaderno: una sola vertical a la izquierda, del color cálido
   de la marca, como la que traen impresa los cuadernos rayados.

   ⚠️ Va pegada al borde del CONTENIDO y no de la ventana. Puesta a una
   distancia fija del borde, en una pantalla ancha queda flotando en el vacío a
   trescientos píxeles del texto y no se lee como margen, se lee como una raya
   suelta. La cuenta la deja siempre a la misma distancia de la columna. */
.bg__margen {
  top: -30%; bottom: -30%;
  left: calc(50% - var(--wrap) / 2 - 26px);
  width: 2px;
  background: var(--margen);
  opacity: var(--margen-op);
  transform: translate3d(0, var(--y-grid, 0px), 0);
}

@media (max-width: 900px) {
  /* En pantalla angosta no hay lugar para el margen: quedaría encima del texto
     en vez de al costado. */
  .bg__margen { display: none; }
}

/* soft colour bodies. radial gradients rather than blur(): the same look
   without asking the compositor to filter three full-screen layers */
.bg__orb { border-radius: 50%; }
.bg__orb--1 {
  width: 46vw; height: 46vw; left: -8vw; top: 6vh;
  background: radial-gradient(circle, var(--orb-1), transparent 68%);
  transform: translate3d(var(--x-orb1, 0px), var(--y-orb1, 0px), 0);
  animation: drift-a 26s ease-in-out infinite;
}
.bg__orb--2 {
  width: 38vw; height: 38vw; right: -6vw; top: 34vh;
  background: radial-gradient(circle, var(--orb-2), transparent 68%);
  transform: translate3d(var(--x-orb2, 0px), var(--y-orb2, 0px), 0);
  animation: drift-b 34s ease-in-out infinite;
}
.bg__orb--3 {
  width: 30vw; height: 30vw; left: 34vw; bottom: -6vh;
  background: radial-gradient(circle, var(--orb-3), transparent 70%);
  transform: translate3d(0, var(--y-orb3, 0px), 0);
  animation: drift-a 42s ease-in-out infinite reverse;
}

/* the slow breath underneath the scroll-driven drift, on translate so it
   never collides with the transform JS is writing */
@keyframes drift-a {
  0%, 100% { translate: 0 0; }
  50% { translate: 3vw -2vh; }
}
@keyframes drift-b {
  0%, 100% { translate: 0 0; }
  50% { translate: -2.5vw 3vh; }
}

/* faint grain so the gradients read as texture, not a flat glow */
.grain {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  opacity: var(--grain); mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

header, main, footer { position: relative; z-index: 1; }

h1, h2, h3, h4 { line-height: 1.12; letter-spacing: -0.025em; margin: 0; font-weight: 600; }
p { margin: 0; }
a { color: inherit; }

/* the sticky nav is 64px tall; without this it eats the heading it jumps to */
section[id] { scroll-margin-top: 82px; }

.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: var(--pad); }

.skip {
  position: absolute; left: -9999px; top: 8px;
  background: var(--accent); color: var(--on-accent); padding: 10px 16px; border-radius: 8px; z-index: 100;
}
.skip:focus { left: 12px; }

:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 3px; border-radius: 4px; }

/* ---------- nav ---------- */

.nav {
  position: sticky; top: 0; z-index: 50;
  background: var(--nav-bg);
  backdrop-filter: saturate(160%) blur(12px);
  -webkit-backdrop-filter: saturate(160%) blur(12px);
  border-bottom: 1px solid transparent;
  transition: border-color 0.25s ease, background-color 0.35s ease;
}
.nav.is-stuck { border-bottom-color: var(--line); }

/* reading progress: the only thing on the page that tracks the scroll exactly */
.progress { position: absolute; inset: auto 0 -1px 0; height: 2px; pointer-events: none; }
.progress span {
  display: block; height: 100%; width: 0;
  background: linear-gradient(90deg, var(--warm), var(--accent));
}

.nav__inner { display: flex; align-items: center; justify-content: space-between; gap: 24px; height: 68px; }


/* Two groups instead of one. The sections sit where reading starts; the
   controls keep to themselves on the other side. Before this everything was
   one list pushed right by space-between, so the whole bar lived in the right
   half and the left held two letters. */
.nav__links { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; }
.nav__end { display: flex; align-items: center; gap: 10px; flex: none; }
.nav__links a {
  text-decoration: none; color: var(--muted); font-size: 15px;
  padding: 9px 14px; border-radius: 9px; transition: color 0.18s ease, background 0.18s ease;
}
/* :not(.btn) matters: `.nav__links a:hover` outranks `.btn`, so without it the
   accent button paints the hover wash over itself and loses its own fill */
.nav__links a:not(.btn):hover { color: var(--text); background: var(--hover-wash); }
.nav__links a.is-active:not(.btn) { color: var(--text); }

/* language and theme, the two controls that must survive the mobile collapse */
.switches { display: flex; align-items: center; gap: 5px; margin-inline: 8px; }
.sw {
  appearance: none; border: 1px solid var(--line); background: transparent;
  color: var(--muted); cursor: pointer;
  height: 32px; min-width: 32px; padding: 0 8px; border-radius: 8px;
  display: inline-grid; place-items: center;
  font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em;
  transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.sw:hover { color: var(--text); background: var(--hover-wash); border-color: var(--line-hi); }
.sw svg { width: 15px; height: 15px; }
/* one icon per theme, and only the relevant one is ever in the box */
.sw__sun { display: none; }
[data-theme="light"] .sw__sun { display: block; }
[data-theme="light"] .sw__moon { display: none; }

@media (max-width: 1080px) { .nav__links a:not(.btn) { padding: 8px 10px; font-size: 14px; } }
@media (max-width: 860px) { .nav__links a:not(.btn) { display: none; } }

/* ---------- buttons ---------- */

.btn {
  display: inline-flex; align-items: center; gap: 8px;
  background: var(--accent); color: var(--on-accent);
  font-weight: 600; font-size: 15px; text-decoration: none;
  padding: 11px 20px; border-radius: 10px;
  transition: transform 0.18s ease, filter 0.18s ease, background 0.25s ease;
}
.btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
.btn--sm { padding: 7px 14px; font-size: 14px; }
.btn--ghost {
  background: transparent; color: var(--text);
  border: 1px solid var(--line-hi);
}
.btn--ghost:hover { background: var(--hover-wash); filter: none; }
.nav__links .btn:hover { background: var(--accent); color: var(--on-accent); }
.nav__links .btn--ghost:hover { background: var(--hover-wash); color: var(--text); }
.nav__links .btn--sm { color: var(--on-accent); }

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

.hero { position: relative; padding: clamp(80px, 13vw, 168px) 0 clamp(72px, 10vw, 128px); overflow: hidden; }

/* Copy on the left, orbit on the right, and the hero runs a little wider than
   the rest of the page so the second column does not squeeze the headline into
   five lines. Below 1180px the orbit is dropped rather than stacked: it is
   decoration, and a phone has better uses for 380px of height. */
.hero .wrap { max-width: 1200px; }
/* ⚠️ 318 Y NO 380, Y SALE DE UNA MEDICION. Con la columna del arte en 380, la
   de texto quedaba en 696px y "Estrategia, producto y código." necesita 744:
   el titular se partia y dejaba "código." colgando solo en un renglon. Los
   garabatos son decoracion y aguantan 60px menos; un titular partido en dos
   por una palabra, no. El castellano manda acá porque es el que corre largo. */
.hero__grid { display: grid; grid-template-columns: minmax(0, 1fr) 318px; gap: 44px; align-items: center; }
.hero__copy { min-width: 0; }
.hero__art { display: grid; place-items: center; }
@media (max-width: 1180px) {
  .hero .wrap { max-width: var(--wrap); }
  .hero__grid { grid-template-columns: 1fr; }
  .hero__art { display: none; }
}
.glow {
  position: absolute; inset: -30% -10% auto -10%; height: 680px; pointer-events: none;
  background:
    radial-gradient(48% 54% at 20% 38%, var(--glow-1), transparent 70%),
    radial-gradient(42% 48% at 82% 20%, var(--glow-2), transparent 70%),
    radial-gradient(30% 34% at 55% 68%, var(--glow-3), transparent 72%);
  filter: blur(24px);
  /* JS writes --sy on scroll; the glow drifts slower than the copy in front */
  transform: translate3d(0, var(--sy, 0px), 0);
  will-change: transform;
}

/* flex-start, not center: the Spanish copy wraps to three lines on a phone,
   and a centred dot would sit beside the middle one */
.eyebrow {
  display: inline-flex; align-items: flex-start; gap: 9px;
  font-family: var(--mono); font-size: 13px; letter-spacing: 0.01em;
  color: var(--text-mid);
  border: 1px solid var(--line); background: var(--surface);
  padding: 6px 14px; border-radius: 999px; margin-bottom: 34px;
}
.dot {
  width: 7px; height: 7px; border-radius: 50%; background: var(--accent-ink);
  box-shadow: 0 0 0 3px var(--accent-soft);
  flex-shrink: 0; margin-top: 0.55em; /* lines the dot up with the first row of text */
}

.hero h1 { font-size: clamp(2.4rem, 5.2vw, 3.7rem); font-weight: 600; margin-bottom: 34px; }
/* Spanish runs about a fifth longer than English for the same sentence, so it
   gets its own step on the scale rather than a compromised headline */
[data-lang="es"] .hero h1 { font-size: clamp(2.1rem, 4.6vw, 3.2rem); }

.lede { max-width: 46ch; font-size: clamp(1.02rem, 1.6vw, 1.18rem); color: var(--text-mid); }
.lede strong { color: var(--text); font-weight: 600; }
.lede--sub { margin-top: 22px; color: var(--muted); }

.cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 44px; }

.stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 1px; margin: clamp(64px, 8vw, 96px) 0 0; padding: 0;
  background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden;
}
.stats > div { background: var(--bg); padding: 20px 22px; transition: background-color 0.35s ease; }
.stats dt { font-size: 13px; color: var(--muted); margin-bottom: 6px; }
.stats dd {
  margin: 0; font-family: var(--mono); font-size: clamp(1.5rem, 3vw, 1.9rem);
  font-weight: 500; letter-spacing: -0.03em;
}

/* ---------- sections ---------- */

/* La credencial va debajo de los numeros y no entre ellos: no es una medida,
   es un dato, y metida en la grilla obliga a leerla como si compitiera con
   "14 productos". Tipografia de etiqueta, del mismo tamanio que los rotulos de
   la fila de arriba, para que se lea como pie y no como titular.

   ⚠️ Va DESPUES del marcador de seccion y no antes. La primera vez quedo justo
   antes, y el borrado del CSS muerto de la orbita, que cortaba hasta ese
   marcador, se la llevo puesta en el mismo tajo. El archivo decia tener la
   regla porque yo la habia escrito; el servido no la tenia. */
.credencial {
  margin: 14px 0 0;
  font-family: var(--mono); font-size: 12px; letter-spacing: 0.04em;
  color: var(--muted);
}

.section { padding: clamp(64px, 10vw, 108px) 0; border-top: 1px solid var(--line); }
.section--alt { background: var(--bg-alt); transition: background-color 0.35s ease; }

.section__head { max-width: 58ch; margin-bottom: 48px; }
.section__head h2 { font-size: clamp(1.7rem, 3.4vw, 2.3rem); margin-bottom: 14px; }
.section__head p { color: var(--muted); }

/* ---------- the scattered marks in the hero ---------- */

/* What replaced the orbit. A ring with things going round it is the stock
   illustration of "AI platform", and a perfect circle is the one shape a hand
   never makes. These are marks dropped the way you fill a margin while
   thinking, each drifting at its own rate.

   Placement is by hand in the markup and not by a loop: nine marks on a grid
   look like nine marks on a grid, and the point is that somebody put them
   there. */
.garabatos { position: relative; width: 100%; height: 100%; min-height: 420px; }

.garabato {
  position: absolute;
  left: var(--x); top: var(--y);
  width: var(--t); height: auto;
  rotate: var(--g);
  opacity: 0.42;
  /* One number on the parent, multiplied here. Ten elements written from JS
     every frame is ten style recalcs for a decoration. */
  translate: 0 calc(var(--desliz, 0px) * var(--p));
  pointer-events: none;
}

/* Same trick as the drawn icons: the ink is pure black, so dark mode is an
   inversion and not a second set of files. */
[data-theme="dark"] .garabato { filter: invert(1); opacity: 0.3; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .garabato { filter: invert(1); opacity: 0.3; }
}
[data-theme="light"] .garabato { filter: none; opacity: 0.42; }

/* Reduced motion keeps the drawing and drops the drift: the marks are content
   here, not an effect, so hiding them would empty the panel. */
@media (prefers-reduced-motion: reduce) {
  .garabato { translate: none; }
}

@media (max-width: 900px) {
  .garabatos { min-height: 260px; }
  .garabato { width: calc(var(--t) * 0.7); opacity: 0.3; }
}

/* ---------- work cards ---------- */

/* the stage exists so the mascots anchor to the grid and not to the section
   heading above it, which is what `position: relative` on .wrap would give */
.cards-stage { position: relative; }
.cards {
  position: relative; z-index: 1;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 18px;
}

.card {
  position: relative; overflow: hidden;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 26px;
  display: flex; flex-direction: column; gap: 14px;
}
.card::after {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 1px; z-index: 2;
  background: linear-gradient(90deg, transparent, var(--accent), transparent);
  opacity: 0; transition: opacity 0.25s ease;
}
.card:hover { border-color: var(--line-hi); background: var(--surface-hi); transform: translateY(-3px); }
.card:hover::after { opacity: 0.6; }

.card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.card__title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.card__logo {
  width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0;
  background: var(--surface-hi); border: 1px solid var(--line);
  display: grid; place-items: center; padding: 6px;
}
.card__logo img { width: 100%; height: 100%; object-fit: contain; }
.card h3 { font-size: 1.22rem; }
.card h3 a { text-decoration: none; display: inline-flex; align-items: baseline; gap: 5px; }
.card h3 a:hover { color: var(--accent-ink); }
.arrow { font-size: 0.78em; color: var(--muted); transition: transform 0.2s ease, color 0.2s ease; }
.card h3 a:hover .arrow { color: var(--accent-ink); transform: translate(2px, -2px); }
.card p { color: var(--text-mid); font-size: 0.96rem; }

.pill {
  font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
  color: var(--muted); border: 1px solid var(--line); border-radius: 999px;
  padding: 3px 9px; white-space: nowrap;
}
.pill--live { color: var(--accent-ink); border-color: var(--accent-line); background: var(--accent-soft); }

/* the role line used to hold the bottom of the card; the tags do it now, so
   a short description and a long one still line their chips up across a row */
.tags { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; padding: 0; margin: 0; }
.card .tags { margin-top: auto; padding-top: 4px; }
.tags li {
  font-size: 12px; color: var(--muted);
  border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px;
}

/* ---------- mascots ---------- */

/* the hornero is dametrabajo's, the carpincho is Nuchus's, and both face into
   the grid. Decoration only: aria-hidden, no pointer events, behind the cards,
   and gone below 1180px where there is no margin left to hold them. */
.mascot {
  position: absolute; z-index: 0; pointer-events: none;
  opacity: 0; /* JS fades them in once the section is reached */
  transition: opacity 0.9s var(--ease-out);
  transform: translate3d(0, var(--my, 0px), 0);
  will-change: transform;
}
.mascot.is-in { opacity: var(--mascot-opacity); }
.mascot img {
  display: block; width: 100%; height: auto;
  /* 🔴 EN OSCURO SE INVIERTEN, COMO EL RESTO DE LOS DIBUJOS. Los tokens viejos
     eran para las ilustraciones a color: aclaraban un 6% y bajaban contraste,
     lo cual sobre tinta negra pura no hace absolutamente nada y dejaba tres
     personajes negros sobre un fondo negro. Es la misma regla que los iconos:
     el trazo es RGB 0,0,0 exacto, asi que invertirlo da blanco puro con el
     alfa intacto y un solo archivo sirve para los dos temas. */
  filter: var(--mascot-filter);
  animation: bob 9s ease-in-out infinite;
}
/* each one leans into the outer edge of the card it belongs to: the hornero
   on dametrabajo, the carpincho on Nuchus, both in the first row */
/* 🔴 SE CORREN TODO LO QUE EL AIRE PERMITE, Y ESE AIRE ES UNA CUENTA, NO UN
   NUMERO. `(100vw - var(--wrap)) / 2` es exactamente lo que sobra a cada lado
   del contenido: correrse mas que eso no es "mas afuera", es fuera de la
   pantalla, y deja la pagina arrastrable de costado. Ya paso: el carpincho con
   `right: -128px` fijo se salia 8px entre 1200 y 1280.

   ⚠️ Y POR ESO TAMBIEN ENCOGEN. Con el corrimiento topeado por el aire, en una
   pantalla angosta el bicho se queda quieto contra el borde y la tarjeta le
   come cada vez mas encima. Achicandolo, lo que la tarjeta tapa sigue siendo
   mas o menos la misma proporcion en vez de crecer hasta dejar una oreja.

   Debajo de 1150 no hay aire que alcance: quedarian dos manchas asomando un
   cuarto detras de una tarjeta, que se lee como un error de maquetado y no
   como un personaje. Ahi se van. */
.mascot--hornero {
  width: clamp(160px, 15vw, 250px);
  left: calc(-1 * (100vw - var(--wrap)) / 2);
  top: -34px;
}
.mascot--carpincho {
  width: clamp(168px, 15.5vw, 262px);
  right: calc(-1 * (100vw - var(--wrap)) / 2);
  top: -10px;
}
/* Nova va arriba de la tarjeta del medio, y esta si puede quedar tapada: no
   necesita aire al costado porque no lo hay, y asomando por encima del borde
   se lee igual. Los ojos y la sonrisa estan en el tercio de arriba, que es
   justo lo que queda a la vista. */
.mascot--nova {
  width: clamp(84px, 8vw, 118px);
  left: 50%; translate: -50% 0;
  top: -46px;
  z-index: 0;
}
.mascot--nova img { animation-duration: 7s; animation-delay: -1.5s; }

@media (max-width: 1150px) {
  /* Los de los costados se van porque no hay aire; Nova se queda, porque
     nunca dependio del costado. */
  .mascot--hornero, .mascot--carpincho { display: none; }
}
@media (max-width: 700px) {
  .mascot--nova { display: none; }
}
.mascot--carpincho img { animation-duration: 11s; animation-delay: -3s; }

@keyframes bob {
  0%, 100% { translate: 0 0; }
  50% { translate: 0 -14px; }
}

@media (max-width: 1180px) { .mascot { display: none; } }

/* ---------- also built ---------- */

.also { margin-top: 56px; padding-top: 42px; border-top: 1px solid var(--line); position: relative; z-index: 1; }
.also__head h3 { font-size: 1.1rem; margin-bottom: 8px; }
.also__head p { color: var(--muted); font-size: 0.94rem; }

.sidelist {
  list-style: none; margin: 24px 0 0; padding: 0;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); overflow: hidden;
}
.sideitem {
  display: grid; grid-template-columns: auto auto 1fr auto;
  align-items: center; gap: 18px;
  padding: 18px 22px;
}
.sideitem:hover { background: var(--surface-hi); }
.sideitem + .sideitem { border-top: 1px solid var(--line); }

.sideitem__idx { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.sideitem__logo {
  width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
  background: var(--surface-hi); border: 1px solid var(--line);
  display: grid; place-items: center; padding: 5px;
}
.sideitem__logo img { width: 100%; height: 100%; object-fit: contain; }
.sideitem__logo--letter { padding: 0; font-family: var(--mono); font-size: 13px; color: var(--muted); }

.sideitem__body h4 { font-size: 0.98rem; margin-bottom: 4px; }
.sideitem__body h4 a { text-decoration: none; display: inline-flex; align-items: baseline; gap: 5px; }
.sideitem__body h4 a:hover, .sideitem__body h4 a:hover .arrow { color: var(--accent-ink); }
.sideitem__body h4 a:hover .arrow { transform: translate(2px, -2px); }
.sideitem__body p { color: var(--muted); font-size: 0.88rem; line-height: 1.5; }

@media (max-width: 640px) {
  .sideitem { grid-template-columns: auto 1fr; gap: 8px 14px; align-items: start; padding: 18px; }
  .sideitem__idx { display: none; }
  .sideitem .pill { grid-column: 2; justify-self: start; }
}

/* ---------- the arc ---------- */

/* TWO MODES, AND THE STATIC ONE IS THE REAL PAGE.

   Without JS, without the width to pin, or under prefers-reduced-motion, this
   is a plain vertical list with all six steps written out and readable. That
   ordering is the point: a reveal that starts at opacity 0 and waits for a
   scroll event ships as invisible text in the static HTML and never draws in
   a headless render. The scrub enhances a default that is already there.

   `is-scrubbing` turns the list on its side into a rail you travel: the nodes
   spread across the screen with their labels underneath, the line draws from
   the first to the last, and one shared panel below carries the story of
   wherever you are. */

.arc { position: relative; list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }

.arc__step {
  position: relative;
  display: grid; grid-template-columns: 52px 1fr; grid-template-rows: auto auto;
  column-gap: 24px;
  padding: 20px 0 24px;
}
.arc__node { grid-row: 1 / span 2; }
.arc__label, .arc__body { grid-column: 2; }

/* the static rail: one segment per step, so it always stops at the last node
   instead of overshooting into the padding */
.arc__step::before {
  content: ""; position: absolute; left: 25px; top: 22px; bottom: -4px;
  width: 1px; background: var(--line);
  transform-origin: top center; transform: scaleY(0);
  transition: transform 0.7s var(--ease-out) 0.2s;
}
.arc__step:last-child::before { display: none; }
.js .arc__step.is-in::before { transform: scaleY(1); }
/* nothing marks them is-in once the scrub owns the section */
.arc.is-scrubbing .arc__step::before { display: none; }

/* No box. The drawing is the whole thing, and a rounded rectangle around a
   hand-drawn icon is the machine putting the sketch back in a frame. */
.arc__node {
  position: relative; z-index: 1;
  width: 52px; height: 52px;
  display: grid; place-items: center;
  color: var(--accent-ink);
  transition: scale 0.35s var(--ease-out);
}

/* The ring gets drawn on, and it needs to be a real svg for that: a
   background image cannot have a stroke-dashoffset animated, and the drawing
   is the point. pathLength="1" renormalises the path so the two lines below
   work at any size without measuring anything. */
/* ⚠️ Las medidas van explicitas y no salen de `inset`. Un svg es un elemento
   reemplazado, asi que con `width: auto` los insets no lo estiran: se queda
   con su tamano intrinseco y `top`/`left` deciden solos. Salia un circulito
   chico arriba del icono en vez de un anillo alrededor. */
.arc__anillo {
  position: absolute; top: -8px; left: -8px;
  width: calc(100% + 16px); height: calc(100% + 16px);
  pointer-events: none; overflow: visible;
}
.arc__anillo path {
  stroke: var(--accent-ink); stroke-width: 2.4; stroke-linecap: round;
  /* Sin cerrar y con el radio desparejo, entre 39 y 45 sobre un lienzo de 100.
     Un circulo perfecto y cerrado se lee como un anillo de interfaz; lo que se
     pidio es el gesto de rodear algo a mano, y ese gesto deja un hueco. */
  vector-effect: non-scaling-stroke;
  stroke-dasharray: 1; stroke-dashoffset: 1;
  transition: stroke-dashoffset 0.55s var(--ease-out);
}
.arc__step:hover .arc__anillo path { stroke-dashoffset: 0; }

@media (prefers-reduced-motion: reduce) {
  .arc__anillo path { transition: none; }
}

/* ---------- the drawn assets ---------- */

/* 🔴 ONE FILE PER DRAWING, AND DARK MODE IS A FILTER. The PNGs come out of
   scripts/limpiar-imagen.py as pure black on transparent, RGB exactly 0,0,0 on
   every opaque pixel, which is the whole reason invert(1) works: it returns
   pure white with the alpha untouched. Ink that came out greyish would invert
   into a dirty grey, and the fix would be a second set of files to keep in
   sync with the first. Worth checking whenever a new one is added. */
.arc__ico,
.lima__cara {
  display: block; width: auto; object-fit: contain;
}
[data-theme="dark"] .arc__ico,
[data-theme="dark"] .lima__cara { filter: invert(1); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .arc__ico,
  :root:not([data-theme="light"]) .lima__cara { filter: invert(1); }
}
[data-theme="light"] .arc__ico,
[data-theme="light"] .lima__cara { filter: none; }

.arc__ico { width: 26px; height: 26px; }

/* Square and whole, not cropped. The bust is nearly square already, so
   `contain` in a square box shows the wave, and the wave is what makes it read
   as somebody offering to help rather than a face in a box. */
.lima__cara {
  width: 46px; height: 46px; flex: none;
  object-fit: contain; object-position: center;
}
.lima__cara--grande { width: 34px; height: 34px; }

.lima__pitch { display: flex; flex-direction: column; gap: 2px; }
.lima__pitch strong { font-size: 14px; font-weight: 600; }
.lima__pitch span { font-size: 12.5px; color: var(--muted); font-weight: 400; }
.arc__step:hover .arc__node { scale: 1.06; }

.arc__label {
  display: inline-block; margin-bottom: 8px;
  font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted);
  transition: color 0.35s ease;
}
.arc__body h3 { font-size: 1.08rem; margin-bottom: 10px; }
.arc__body p { color: var(--muted); font-size: 0.95rem; max-width: 68ch; }

@media (max-width: 620px) {
  .arc__step { grid-template-columns: 40px 1fr; column-gap: 16px; }
  .arc__step::before { left: 19px; }
  .arc__node { width: 40px; height: 40px; }
}

/* ---------- the pinned scene ---------- */

.arc-track.is-pinned { padding-top: 0; padding-bottom: 0; height: 340vh; }
.arc-track.is-pinned .arc-stage {
  position: sticky; top: 0; min-height: 100vh;
  display: flex; flex-direction: column; justify-content: center;
  padding: 48px 0;
}

/* the rail turns horizontal. Its ends land on the first and last node centres
   without measuring anything: six equal columns put those centres at one
   twelfth in from each side, so the geometry is exact by construction. */
.arc__rail {
  position: absolute; display: none;
  left: 8.3333%; right: 8.3333%; top: 26px; height: 1px;
  background: var(--line);
}
.arc.is-scrubbing .arc__rail { display: block; }
.arc__rail-fill {
  position: absolute; inset: 0; display: block;
  background: var(--accent-ink);
  transform-origin: left center; transform: scaleX(var(--draw, 0));
  box-shadow: 0 0 14px var(--accent-soft);
}

.arc.is-scrubbing {
  display: flex; align-items: flex-start; gap: 0;
  padding-bottom: 172px;
}
/* static, not relative: the shared panel below is absolutely positioned and
   has to resolve against the whole rail. Leave the step positioned and each
   body is trapped inside its own sixth of the width. */
.arc.is-scrubbing .arc__step {
  flex: 1 1 0; min-width: 0;
  position: static;
  display: block; text-align: center; padding: 0;
}
/* the pointer spotlight resolves against the same ancestor, so it would cover
   the whole rail instead of one step */
.arc.is-scrubbing .arc__step::after { display: none; }
.arc.is-scrubbing .arc__node {
  margin: 0 auto;
  opacity: 0.4; scale: 0.86;
}
.arc.is-scrubbing .arc__step.is-on .arc__node { opacity: 1; scale: 1; }

/* Only the step you are standing on gets circled. Circling every one you have
   passed turns the rail into six rings and says nothing about where you are. */
.arc.is-scrubbing .arc__step.is-current .arc__anillo path { stroke-dashoffset: 0; }
.arc.is-scrubbing .arc__step.is-current .arc__node { scale: 1.1; }

.arc.is-scrubbing .arc__label {
  display: block; margin: 14px 0 0;
  opacity: 0.45; transition: opacity 0.35s ease, color 0.35s ease;
}
.arc.is-scrubbing .arc__step.is-on .arc__label { opacity: 1; }
.arc.is-scrubbing .arc__step.is-current .arc__label { color: var(--accent-ink); }

/* one panel, six stories, and only the one you are standing on is up */
.arc.is-scrubbing .arc__body {
  position: absolute; left: 0; right: 0; top: 122px;
  text-align: left; max-width: 74ch;
  opacity: 0; translate: 0 14px; pointer-events: none;
  /* leaving is quick and arriving waits a beat: both panels share one slot, so
     matched timings would cross-fade two headlines on top of each other */
  transition: opacity 0.18s ease, translate 0.18s ease;
}
.arc.is-scrubbing .arc__step.is-current .arc__body {
  opacity: 1; translate: none; pointer-events: auto;
  transition: opacity 0.4s var(--ease-out) 0.14s, translate 0.45s var(--ease-out) 0.14s;
}
.arc.is-scrubbing .arc__body h3 { font-size: 1.35rem; margin-bottom: 12px; }
.arc.is-scrubbing .arc__body p { font-size: 1rem; color: var(--text-mid); }

/* ---------- craft ---------- */

.craft { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 38px 44px; }

/* Sin caja, igual que los nodos del Recorrido. Un cuadrado redondeado
   alrededor de un trazo a mano es la maquina volviendo a enmarcar el boceto,
   y ademas eran diez marcos compitiendo con el marco del bloque. */
.craft__icon {
  display: grid; place-items: center;
  width: 38px; height: 38px; margin-bottom: 14px;
  color: var(--accent-ink);
  transition: scale 0.5s var(--ease-out), rotate 0.3s var(--ease-out);
}

/* 🔴 EL TRAZO SE DESPROLIJA CON UN FILTRO, Y ACA SI SE PUEDE. En las tarjetas
   el mismo filtro estaba prohibido porque arrastraba el texto con el; un icono
   no tiene texto, asi que desplazar sus pixeles no le cuesta nada a nadie.
   Convierte los diez iconos de linea recta en trazo tembloroso sin pedir diez
   dibujos nuevos.

   La escala del desplazamiento es chica a proposito: a 38px, dos pixeles ya
   son un temblor visible, y mas que eso rompe las formas cerradas. */
.craft__icon svg { filter: url(#tiembla); }
.craft__icon svg { width: 20px; height: 20px; }
.craft__item:hover .craft__icon { scale: 1.12; rotate: -4deg; }
/* the icon lands a beat after the text, which is what stops ten of them
   arriving as one block */
.js .craft__item.reveal:not(.is-in) .craft__icon { scale: 0.75; }

.craft__item h3 { font-size: 1.05rem; margin-bottom: 10px; }
.craft__item p { color: var(--muted); font-size: 0.95rem; }

.stack { margin-top: 56px; padding-top: 34px; border-top: 1px solid var(--line); }
.stack__label {
  font-family: var(--mono); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); font-weight: 500; margin-bottom: 16px;
}
.chips { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; }
.chips li {
  font-family: var(--mono); font-size: 13px; color: var(--text-mid);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 8px; padding: 6px 12px;
}

/* ---------- services ---------- */

/* Craft says what he can do and stops there; this is the same list pointed at
   someone else's problem. Deliberately not the Craft treatment: numbered
   instead of icon-led, two up instead of three, and each one closes on who it
   is for, because "is this me?" is the only question a visitor is asking. */
.offers { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 18px; }

/* 🔴 LA ANCHA ES LA PRIMERA, NO LA QUE SOBRA. Con cinco servicios en dos
   columnas queda una fila con una sola tarjeta, y estirarla es lo que evita que
   se lea como un error de maquetado. Pero estirar la ULTIMA le daba el mayor
   peso visual de la seccion a la que quedo al final por casualidad, y peor:
   contradecia a la numeracion, que dice que la importante es la 01. Dos
   senializaciones opuestas en la misma grilla.
   Estirando la primera, el orden y el tamanio dicen lo mismo, y el orden pasa a
   ser una decision de prioridad en vez de un accidente.

   ⚠️ Solo cuando la cuenta es impar. Con seis vuelven todas a la grilla pareja
   sin que nadie se acuerde de tocar esto. */
.offers:has(.offer:nth-child(odd):last-child) .offer:first-child { grid-column: 1 / -1; }

.offer {
  position: relative; overflow: hidden;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 28px 28px 24px;
  display: flex; flex-direction: column; gap: 12px;
  transition: transform 0.25s var(--ease-out), border-color 0.2s ease, background 0.2s ease;
}
.offer:hover { border-color: var(--line-hi); background: var(--surface-hi); transform: translateY(-3px); }
.offer > * { position: relative; z-index: 1; }

.offer__idx {
  font-family: var(--mono); font-size: 12px; letter-spacing: 0.08em;
  color: var(--accent-ink);
}
.offer h3 { font-size: 1.16rem; }
.offer p { color: var(--text-mid); font-size: 0.95rem; }
.offer__who {
  margin-top: auto; padding-top: 14px;
  border-top: 1px solid var(--line);
  font-size: 0.88rem; color: var(--muted);
}

.offer__foot {
  margin-top: 34px; padding-top: 28px; border-top: 1px solid var(--line);
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 20px;
}
.offer__foot p { color: var(--muted); font-size: 0.95rem; max-width: 62ch; }

/* the pointer spotlight, same as every other card on the page */
.rich .offer::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  border-radius: inherit; pointer-events: none;
  background: radial-gradient(420px circle at var(--mx, 50%) var(--my, 50%), var(--spot), transparent 62%);
  opacity: 0; transition: opacity 0.35s ease;
}
.rich .offer.is-lit::before { opacity: 1; }

/* ---------- skills ---------- */

.skills-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; }

.skill-card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 12px; padding: 20px;
  display: flex; flex-direction: column; gap: 10px;
  text-decoration: none; color: inherit;
}
.skill-card:hover { border-color: var(--line-hi); background: var(--surface-hi); transform: translateY(-3px); }

.skill-icon {
  width: 34px; height: 34px; border-radius: 9px;
  display: grid; place-items: center;
  background: var(--warm-soft); color: var(--warm);
  flex-shrink: 0;
}
.skill-icon svg { width: 18px; height: 18px; }

.skill-card h3 { font-size: 1rem; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.skill-card h3 .arrow { font-size: 0.72em; color: var(--muted); }
.skill-card:hover h3, .skill-card:hover h3 .arrow { color: var(--accent-ink); }
.skill-card p { color: var(--muted); font-size: 0.88rem; margin: 0; }

.skills-cta { margin-top: 26px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }

/* two groups in one section: the ones written on his own knowledge, which go
   nowhere because they are not published, and the open ones that link out */
.skills-band {
  font-family: var(--mono); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); font-weight: 500;
  margin: 0 0 18px; padding-bottom: 12px;
  border-bottom: 1px solid var(--line);
}
.skills-grid + .skills-band { margin-top: 56px; }
/* no link, no arrow, and a mark that says why: the card is a statement of what
   exists, not an invitation to click something that is not there */
.skill-card--own { cursor: default; }
.skill-card--own:hover { transform: none; }
.rich .skill-card--own:hover { transform: perspective(1400px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); }
.skill-card--own h3 { justify-content: space-between; gap: 10px; }
.skill-card--own:hover h3 { color: inherit; }
.skill-mark {
  font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--muted); border: 1px solid var(--line-hi); border-radius: 999px;
  padding: 2px 8px; font-weight: 500; white-space: nowrap;
}

/* The thirteen that did not make the grid, as names only. A line of copy each
   turned the tail of the section into more reading than the six cards above
   it, which inverts the emphasis this section is supposed to have.

   The dashed ring on the private ones is the whole affordance: it says there
   is nothing to click before anyone tries, which the solid ones do not need
   because they carry an arrow. */
.minis { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 22px 0 0; padding: 0; }
.mini {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--mono); font-size: 13px; color: var(--text-mid);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 9px; padding: 8px 13px; text-decoration: none;
  transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease,
              transform 0.18s var(--ease-out);
}
a.mini:hover {
  color: var(--accent-ink); border-color: var(--line-hi);
  background: var(--surface-hi); transform: translateY(-1px);
}
a.mini:hover .arrow { color: var(--accent-ink); }
.mini .arrow { font-size: 0.78em; }
.mini--own { border-style: dashed; border-color: var(--line-hi); color: var(--muted); }

/* ---------- activity ---------- */

.heat { margin: 0 0 32px; }
.heat__scroll { overflow-x: auto; padding-bottom: 10px; }
.heat__grid {
  display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 1fr);
  gap: 3px; min-width: 700px;
}
/* no background here: `.heat__grid i` would outrank the `.lvN` classes
   and paint every cell the same empty grey */
.heat__grid i {
  width: 11px; height: 11px; border-radius: 2px; display: block;
  border: 1px solid var(--line);
}
.lv0 { background: var(--surface); }
.lv1 { background: var(--lv1); }
.lv2 { background: var(--lv2); }
.lv3 { background: var(--lv3); }
.lv4 { background: var(--lv4); }

.heat__caption {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px;
  font-size: 13px; color: var(--muted); margin-top: 12px;
}
.heat__legend { display: inline-flex; align-items: center; gap: 4px; }
.heat__legend .lv { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }

.note { color: var(--muted); font-size: 0.93rem; max-width: 62ch; margin-bottom: 18px; }
.note a { color: var(--text-mid); text-decoration: none; border-bottom: 1px solid var(--line-hi); }
.note a:hover, .note a:hover .arrow { color: var(--accent-ink); }
.note a:hover { border-bottom-color: var(--accent-ink); }
.note a .arrow { font-size: 0.8em; margin-left: 3px; }

/* ---------- contact ---------- */

.contact h2 { font-size: clamp(1.9rem, 4.4vw, 2.8rem); margin-bottom: 16px; }
.contact__lede { max-width: 46ch; color: var(--muted); }

.contact__grid {
  /* The side column is the wider one so the four links sit on one row. At
     even widths the last one wraps alone, which reads as a mistake. */
  display: grid; grid-template-columns: minmax(0, 1.07fr) minmax(0, 1fr);
  gap: 46px; align-items: start;
}
.contact__side { display: flex; flex-direction: column; gap: 26px; }

/* ---------- the form ---------- */

.form { display: flex; flex-direction: column; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 7px; }

.field label {
  font-family: var(--mono); font-size: 12px; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--muted);
}

.field input,
.field textarea {
  width: 100%; font: inherit; font-size: 15px; color: var(--text);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: 11px; padding: 12px 14px;
  transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.field textarea { resize: vertical; min-height: 108px; line-height: 1.55; }

.field input:hover,
.field textarea:hover { border-color: var(--line-hi); }

.field input:focus,
.field textarea:focus {
  outline: none; background: var(--surface-hi);
  border-color: var(--accent-line); box-shadow: 0 0 0 3px var(--accent-soft);
}

/* :user-invalid, not :invalid. The plain one paints every empty required
   field red the moment the page loads, which is a form telling you off
   before you have typed anything. This one waits until the field has been
   used and left. Where it is not supported, nothing turns red and the
   browser still refuses to submit. */
.field input:user-invalid,
.field textarea:user-invalid { border-color: var(--warm); }

.form .btn { align-self: start; }
.form .btn:disabled { opacity: 0.55; cursor: progress; }

/* The honeypot. Off-screen rather than display:none, because a bot that
   parses the CSS skips what is not rendered, and this one has to look
   fillable. Nobody tabs into it: tabindex is -1 and it is aria-hidden. */
.trampa {
  position: absolute; left: -9999px; width: 1px; height: 1px;
  overflow: hidden;
}

.form__msg { font-size: 14px; color: var(--muted); margin: 0; max-width: 46ch; }
.form__msg--ok { color: var(--accent-ink); }
.form__msg--mal { color: var(--warm); }

/* Sent: the fields go, the line stays. An empty form under a success message
   reads like nothing happened. */
.form.is-sent .field,
.form.is-sent .trampa,
.form.is-sent .btn { display: none; }

@media (max-width: 900px) {
  .contact__grid { grid-template-columns: 1fr; gap: 34px; }
}

/* ---------- footer ---------- */

.foot { border-top: 1px solid var(--line); padding: 30px 0; }
.foot__inner {
  display: flex; flex-wrap: wrap; gap: 8px 24px; justify-content: space-between;
  font-size: 13px; color: var(--muted);
}

/* ---------- Lima, the assistant in the corner ---------- */

.oculto {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

.lima {
  position: fixed; right: 22px; bottom: 22px; z-index: 60;
  display: flex; flex-direction: column; align-items: flex-end; gap: 12px;
  opacity: 0; translate: 0 14px; pointer-events: none;
  transition: opacity 0.4s var(--ease-out), translate 0.4s var(--ease-out);
}
.lima.is-visible { opacity: 1; translate: 0 0; pointer-events: auto; }

/* A card, not a pill. At 26px the bust was a smudge that read as a stain, and
   a launcher nobody can identify is a launcher nobody opens. Square and big
   enough to be a person, with a line saying what it is for, because "Axel's
   assistant" on its own does not say that it answers. */
.lima__launch {
  display: inline-flex; align-items: center; gap: 12px; text-align: left;
  font: inherit; font-size: 14px; font-weight: 500; color: var(--text);
  background: var(--surface); border: 1px solid var(--line-hi);
  border-radius: 16px; padding: 11px 16px 11px 11px; cursor: pointer;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
  transition: border-color 0.18s ease, background 0.18s ease,
              translate 0.18s var(--ease-out);
}
.lima__launch:hover { background: var(--surface-hi); border-color: var(--accent-line); translate: 0 -2px; }
.lima__launch:focus-visible { outline: 2px solid var(--accent-line); outline-offset: 3px; }

/* The dot is the whole avatar. A drawn character here would be a third
   mascot competing with the hornero and the carpincho, which belong to
   products and not to this page. */
.lima__dot {
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--accent); flex: none;
}
/* Con el panel abierto el lanzador sobra y encima queda flotando por arriba,
   pisando el borde. Se va, y la cruz del encabezado es la unica salida. */
.lima.is-open .lima__launch { display: none; }

.lima__panel {
  /* 🔴 ABSOLUTO, Y NO ES UN DETALLE DE ACOMODO. Al pasar de `hidden` a
     `visibility` para poder animarlo, el panel dejo de verse pero SIGUIO
     OCUPANDO LUGAR: el contenedor media el alto de los dos y el lanzador
     terminaba flotando en el medio de la pantalla en vez de abajo a la
     derecha. Sacarlo del flujo es lo que permite que se pueda animar sin que
     su tamanio empuje nada. */
  position: absolute; right: 0; bottom: calc(100% + 12px);
  width: min(430px, calc(100vw - 44px));
  display: flex; flex-direction: column;
  background: var(--bg-alt); border: 1px solid var(--line-hi);
  border-radius: 18px; overflow: hidden;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);

  /* 🔴 SE ABRE DESDE EL LANZADOR, no aparece en el medio de la nada. El origen
     de la transformacion es la esquina de abajo a la derecha, que es de donde
     lo llamaste: crece desde ahi en vez de materializarse, y eso es lo que
     hace que el gesto se lea como una respuesta y no como un cartel.

     ⚠️ Y NO SE USA `hidden` PARA ESCONDERLO. `display: none` no interpola,
     asi que con el atributo no hay animacion posible: no es que quede fea, es
     que no existe. El atributo se saca una sola vez cuando arranca el script,
     y de ahi en mas manda la clase. Si el script nunca corre, `hidden` se
     queda puesto y el panel no aparece, que es la degradacion correcta.

     `visibility` se demora hasta el final al cerrar y va sin demora al abrir:
     sin eso el panel sigue siendo enfocable mientras se desvanece. */
  opacity: 0; visibility: hidden;
  scale: 0.9; translate: 0 14px;
  transform-origin: bottom right;
  transition: opacity 0.22s ease,
              scale 0.34s var(--ease-out),
              translate 0.34s var(--ease-out),
              visibility 0s linear 0.34s;
}
.lima.is-open .lima__panel {
  opacity: 1; visibility: visible;
  scale: 1; translate: 0 0;
  transition: opacity 0.2s ease,
              scale 0.38s cubic-bezier(0.2, 1.1, 0.35, 1),
              translate 0.38s cubic-bezier(0.2, 1.1, 0.35, 1),
              visibility 0s;
}
.lima__panel[hidden] { display: none; }

/* Cada burbuja entra por su cuenta. Una charla que aparece de golpe se lee
   como una pantalla que se pinto; de a una se lee como alguien contestando. */
@keyframes lima-entra {
  from { opacity: 0; translate: 0 8px; }
  to   { opacity: 1; translate: 0 0; }
}
.lima__msg { animation: lima-entra 0.32s var(--ease-out) both; }

@media (prefers-reduced-motion: reduce) {
  .lima__panel { transition: opacity 0.15s ease, visibility 0s; scale: 1; translate: 0; }
  .lima__msg { animation: none; }
}

.lima__head {
  display: flex; align-items: center; gap: 10px;
  padding: 13px 14px; border-bottom: 1px solid var(--line);
  background: var(--surface);
}
.lima__who { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.lima__who strong { font-size: 14px; }
.lima__sub {
  font-family: var(--mono); font-size: 11px; letter-spacing: 0.03em;
  color: var(--muted);
}
.lima__x {
  font: inherit; font-size: 20px; line-height: 1; color: var(--muted);
  background: none; border: 0; cursor: pointer; padding: 4px 6px;
  border-radius: 8px; flex: none;
}
.lima__x:hover { color: var(--text); background: var(--hover-wash); }

.lima__log {
  display: flex; flex-direction: column; gap: 10px;
  padding: 15px 14px; overflow-y: auto;
  /* Grows with the conversation instead of opening at full height. A fixed
     box under a one line greeting reads as a panel that failed to load. */
  min-height: 230px; max-height: min(460px, 58vh);
  overscroll-behavior: contain;
}

.lima__msg {
  font-size: 14.5px; line-height: 1.55; max-width: 88%;
  padding: 10px 13px; border-radius: 14px;
  white-space: pre-wrap; overflow-wrap: anywhere;
}
.lima__msg--bot {
  align-self: flex-start; color: var(--text-mid);
  background: var(--surface); border: 1px solid var(--line);
  border-bottom-left-radius: 5px;
}
.lima__msg--yo {
  align-self: flex-end; color: var(--text);
  background: var(--accent-soft); border: 1px solid var(--accent-line);
  border-bottom-right-radius: 5px;
}
.lima__msg.is-waiting { color: var(--muted); animation: lima-latir 1.3s ease-in-out infinite; }

@keyframes lima-latir { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } }

.lima__salto {
  align-self: flex-start; font: inherit; font-size: 13px; font-weight: 500;
  color: var(--on-accent); background: var(--accent);
  border: 0; border-radius: 10px; padding: 8px 13px; cursor: pointer;
  transition: opacity 0.18s ease;
}
.lima__salto:hover { opacity: 0.86; }

.lima__ask {
  display: flex; gap: 8px; align-items: center;
  padding: 11px 12px; border-top: 1px solid var(--line);
  background: var(--surface);
}
.lima__ask[hidden] { display: none; }
.lima__ask input {
  flex: 1; min-width: 0; font: inherit; font-size: 14px; color: var(--text);
  background: var(--bg-alt); border: 1px solid var(--line);
  border-radius: 11px; padding: 10px 12px;
  transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.lima__ask input:focus {
  outline: none; border-color: var(--accent-line);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.lima__ask input:disabled { opacity: 0.6; }

.lima__send {
  flex: none; font: inherit; font-size: 17px; line-height: 1;
  color: var(--on-accent); background: var(--accent);
  border: 0; border-radius: 11px; padding: 10px 14px; cursor: pointer;
  transition: opacity 0.18s ease;
}
.lima__send:hover { opacity: 0.86; }
.lima__send:focus-visible { outline: 2px solid var(--accent-line); outline-offset: 2px; }

@media (max-width: 560px) {
  .lima { right: 14px; bottom: 14px; left: 14px; align-items: stretch; }
  .lima__launch { align-self: flex-end; }
  .lima__panel { width: 100%; }
  .lima__log { min-height: 190px; max-height: min(340px, 46vh); }
}

@media (prefers-reduced-motion: reduce) {
  .lima { transition: none; }
  .lima__msg.is-waiting { animation: none; opacity: 0.6; }
}

/* ---------- the theme change, as ink ---------- */

/* The default cross-fade is switched off so the clip-path animation that
   app.js runs is the only thing describing the change. mix-blend-mode goes
   back to normal for the same reason: blended, the two photos tint each other
   through the stain and the edge stops reading as a front. */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

/* Which photo sits on top, and it flips with the direction. Spilling, the new
   dark view advances and has to cover the old one. Draining, what withdraws
   is the OLD one, and if the new were on top nothing would be seen emptying. */
html[data-transicion="llenando"]::view-transition-old(root) { z-index: 1; }
html[data-transicion="llenando"]::view-transition-new(root) { z-index: 2; }
html[data-transicion="vaciando"]::view-transition-new(root) { z-index: 1; }
html[data-transicion="vaciando"]::view-transition-old(root) { z-index: 2; }


/* ---------- la version de telefono ---------- */

/* Dos patrones que arma mobile.js en el navegador. En escritorio nada de esto
   existe, porque las clases no se agregan. Medido antes: la pagina media
   15.353px de alto en un telefono. */

/* El acordeon. La altura se anima con grid-template-rows de 0fr a 1fr, que es
   lo unico que interpola una altura desconocida sin medirla: `height: auto` no
   anima, y medir con scrollHeight se rompe apenas el texto reflowea. */
.plegable {
  display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows 0.38s var(--ease-out);
}
.plegable > * { overflow: hidden; min-height: 0; }
.esta-abierto > .plegable { grid-template-rows: 1fr; }

.plegable__tirador {
  display: flex; align-items: center; gap: 10px; width: 100%;
  font: inherit; color: inherit; text-align: left;
  background: none; border: 0; padding: 0; cursor: pointer;
}
/* La flecha es el unico elemento que dice que esto se abre. Sin ella el titulo
   parece un titulo y nadie lo toca. */
.plegable__tirador::after {
  content: ""; flex: none; margin-left: auto;
  width: 9px; height: 9px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  rotate: 45deg; translate: 0 -2px;
  transition: rotate 0.3s var(--ease-out);
}
.esta-abierto > .plegable__tirador::after { rotate: -135deg; translate: 0 2px; }

@media (prefers-reduced-motion: reduce) {
  .plegable { transition: none; }
  .plegable__tirador::after { transition: none; }
}

.offer__cta { margin-top: 16px; }

/* "Tambien construi" en telefono: nombre y estado, sin la descripcion. Cuatro
   filas de tres renglones cada una son doce renglones para decir cuatro
   nombres; la descripcion esta a un toque de distancia en el sitio de cada
   uno, que es adonde va a ir igual quien se interese. */
@media (max-width: 760px) {
  .sideitem__body p { display: none; }
  .sideitem { padding: 14px 16px; gap: 12px; }
}

/* Medio anio entra sin arrastrar: 27 columnas contra 53. El ancho minimo del
   escritorio esta pensado para el anio entero y aca sobra. */
@media (max-width: 760px) {
  .heat__grid { min-width: 0; }
  .heat__scroll { overflow-x: visible; }
}

/* El carrusel. Scroll horizontal con anclaje, para que las tarjetas frenen
   enteras y no a la mitad. */
.es-carrusel {
  display: flex !important;
  grid-template-columns: none !important;
  overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x mandatory;
  gap: 14px;
  /* ⚠️ SIN MARGEN NEGATIVO. La idea era que sangrara hasta el borde de la
     pantalla, y el precio medido fueron 40px de scroll lateral en toda la
     pagina: el contenedor con `overflow-x: auto` se hace mas ancho que su
     padre y el que desborda pasa a ser el documento. Un sangrado bonito no
     vale que el sitio se pueda arrastrar de costado. */
  padding: 4px 2px 18px;
  scroll-padding-inline: 2px;
  scrollbar-width: none;
}
.es-carrusel::-webkit-scrollbar { display: none; }
.es-carrusel > * {
  flex: 0 0 82%;
  scroll-snap-align: center;
  /* ⚠️ Sin esto las tarjetas se aplastan: en un contenedor flex el ancho base
     manda sobre el contenido y una tarjeta con mas texto se comprime. */
  min-width: 0;
}
/* Las fichas del stack son chicas: van de a varias por pantalla, no una. */
.stack .es-carrusel > * { flex: 0 0 auto; scroll-snap-align: start; }

/* ---------- interaction layer ---------- */

/* Everything here is painted from custom properties that interact.js writes
   inside a rAF, so a moving pointer costs a repaint and never a layout.
   The whole layer is gated on .rich, which only lands on a fine pointer with
   motion allowed: a tilt on a phone is a tap that feels broken. */

/* the spotlight: a soft disc that follows the pointer across the surface */
.rich .card::before,
.rich .skill-card::before,
.rich .craft__item::before,
.rich .arc__step::before,
.rich .sideitem::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  border-radius: inherit; pointer-events: none;
  background: radial-gradient(420px circle at var(--mx, 50%) var(--my, 50%),
              var(--spot), transparent 62%);
  opacity: 0; transition: opacity 0.35s ease;
}
.rich .card.is-lit::before,
.rich .skill-card.is-lit::before,
.rich .craft__item.is-lit::before,
.rich .arc__step.is-lit::before,
.rich .sideitem.is-lit::before { opacity: 1; }

/* the arc already used ::before for its rail, so its spotlight goes on ::after */
.rich .arc__step::before { background: none; }
.rich .arc__step::after {
  content: ""; position: absolute; inset: -8px -12px; z-index: -1;
  border-radius: 16px; pointer-events: none;
  background: radial-gradient(420px circle at var(--mx, 50%) var(--my, 50%),
              var(--spot), transparent 62%);
  opacity: 0; transition: opacity 0.35s ease;
}
.rich .arc__step.is-lit::after { opacity: 1; }

.craft__item, .arc__step { position: relative; }
.craft__item > *, .arc__step > * { position: relative; z-index: 1; }
.card > *, .skill-card > *, .sideitem > * { position: relative; z-index: 1; }

/* the tilt. perspective lives on the parent so a whole row shares one vanishing
   point instead of each card pretending to be the centre of the world */
.cards, .skills-grid, .craft { perspective: 1400px; }
.rich .card, .rich .skill-card {
  transform: perspective(1400px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transform-style: preserve-3d;
  transition: transform 0.4s var(--ease-out), border-color 0.2s ease, background 0.2s ease;
}
.rich .card:hover, .rich .skill-card:hover {
  transform: perspective(1400px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateY(-4px) scale(1.008);
  transition: transform 0.14s linear, border-color 0.2s ease, background 0.2s ease;
}

/* magnetic buttons: the pull is capped under the padding, so the label can
   never leave its own box and the click target stays where the eye put it */
.rich .btn, .rich .sw {
  transform: translate3d(var(--pull-x, 0px), var(--pull-y, 0px), 0);
  transition: transform 0.35s var(--ease-out), filter 0.18s ease, background 0.25s ease,
              color 0.18s ease, border-color 0.18s ease;
}
.rich .btn:hover, .rich .sw:hover {
  transition: transform 0.12s linear, filter 0.18s ease, background 0.25s ease,
              color 0.18s ease, border-color 0.18s ease;
}

/* headlines arriving word by word. the span is inline-block so it can be moved
   at all, and the stagger comes off --wi rather than a rule per word */
.w { display: inline-block; }
/* one selector, not two: `.js .section__head.reveal h2 .w` used to sit here as
   well, and at four classes plus an element it outranked `.js .reveal.is-in .w`,
   so every section heading stayed invisible after it had arrived */
.js .reveal .w {
  opacity: 0; translate: 0 0.5em;
  transition: opacity 0.5s var(--ease-out), translate 0.55s var(--ease-out);
  transition-delay: calc(var(--wi, 0) * 45ms);
}
.js .reveal.is-in .w { opacity: 1; translate: none; }
.js .reveal.is-past .w { opacity: 0; translate: 0 -0.35em; transition-delay: 0ms; }

/* the h1 is not inside a .reveal wrapper, it is the reveal itself */
.js .hero h1.reveal .w { transition-delay: calc(var(--wi, 0) * 55ms); }

/* ---------- reveal on scroll ---------- */

/* Content is visible by default; only JS opts into the animation, so a broken
   observer or a blocked script can never leave the page blank.

   Three states, not two, which is what makes it read like a cut rather than a
   fade: below and unseen, on stage, and gone past the top. Scrolling back up
   replays it, because is-in is toggled and never unobserved.

   --d is the stagger app.js writes from the element's position in its group.

   translate and scale are used instead of transform on purpose: they are
   separate animatable properties, so a card can be sliding into place and
   lifting on hover at the same time without the two fighting over transform. */
.js .reveal {
  opacity: 0; translate: 0 24px;
  transition: opacity 0.7s var(--ease-out), translate 0.7s var(--ease-out), scale 0.7s var(--ease-out);
  transition-delay: var(--d, 0ms);
}
.js .reveal.is-in { opacity: 1; translate: none; scale: none; }
/* already read and scrolled past: leaves upward, and comes back if you return */
.js .reveal.is-past { opacity: 0; translate: 0 -18px; transition-delay: 0ms; }

.js .card.reveal, .js .skill-card.reveal { translate: 0 28px; scale: 0.985; }
.js .card.reveal.is-past, .js .skill-card.reveal.is-past { translate: 0 -20px; scale: 0.99; }
.js .sideitem.reveal { translate: -20px 0; }
.js .sideitem.reveal.is-past { translate: -12px 0; }
.js .stats.reveal { translate: 0 30px; }

/* hover timings live on the component, and stay untouched by the reveal above
   because the reveal only ever animates opacity, translate and scale */
.card, .skill-card { transition: transform 0.25s var(--ease-out), border-color 0.2s ease, background 0.2s ease; }
.sideitem { transition: background 0.2s ease; }

@media (prefers-reduced-motion: reduce) {
  .js .reveal, .js .reveal.is-past,
  .js .card.reveal, .js .skill-card.reveal, .js .sideitem.reveal, .js .stats.reveal {
    opacity: 1; translate: none; scale: none; transition: none;
  }
  .js .craft__item.reveal:not(.is-in) h3::before { transform: none; transition: none; }
  .arc-track.is-pinned { height: auto; padding: clamp(64px, 10vw, 108px) 0; }
  .arc-track.is-pinned .arc-stage { position: static; min-height: 0; padding: 0; }
  .arc__step::before { transform: none; transition: none; }
  .btn:hover, .card:hover, .skill-card:hover { transform: none; }
  .rich .card, .rich .skill-card, .rich .btn, .rich .sw { transform: none; }
  .w { opacity: 1; translate: none; transition: none; }
  .glow, .bg > * { transform: none; animation: none; }
  .mascot img { animation: none; }
  .chip { transform: rotate(var(--a)) translateY(calc(var(--r, 130px) * -1)) rotate(calc(var(--a) * -1)); }
  .arc__step::before { transform: none; transition: none; }
  .js .craft__item.reveal:not(.is-in) .craft__icon { scale: none; }
}
