Saltar al contenido
proyecto-portfolio.css · devschool

Proyecto: tu portfolio de desarrollador web con CSS

Lección 45 de 50 · 17 min de lectura · Actualizado el

En esta lección
  1. Qué cambia respecto a la landing
  2. Planificar antes de escribir
  3. Paso 1: el HTML
  4. Paso 2: los colores de los dos temas
  5. Paso 3: tipografía fluida y espacios
  6. Paso 4: cabecera pegajosa
  7. Paso 5: el héroe con animación de entrada
  8. Paso 6: botones
  9. Paso 7: “Sobre mí” con áreas de Grid
  10. Paso 8: las etiquetas de tecnologías
  11. Paso 9: la rejilla de proyectos
  12. Paso 10: contacto y pie
  13. Paso 11: respetar a quien no quiere movimiento
  14. Paso 12: el botón de tema
  15. Probarlo y publicarlo
  16. Ideas para ampliarlo
  17. Errores frecuentes
  18. Resumen

Un portfolio es tu carta de presentación como desarrollador: una página con quién eres, qué sabes hacer y qué proyectos has construido. Es lo primero que mirará una empresa cuando busques prácticas o tu primer trabajo, y además es en sí mismo una prueba de tu nivel de CSS. En este proyecto vas a maquetar el portfolio de una desarrolladora ficticia, Marta, paso a paso. Usarás variables, Grid con áreas con nombre, Flexbox, modo oscuro con light-dark(), tipografía fluida y animaciones que respetan a quien prefiere menos movimiento.

Qué cambia respecto a la landing

En el Proyecto: landing ya maquetaste una página con cabecera, héroe, tarjetas y formulario, con una media query a 700px. Aquí no vamos a repetir eso. Este proyecto se centra en técnicas distintas:

Landing (proyecto anterior)Portfolio (este proyecto)
Colores fijos en variablesTema claro y oscuro con light-dark() y un botón
Tarjetas en 1 o 3 columnas con media queryRejilla automática con auto-fit y minmax()
Flexbox para imagen y textoGrid con grid-template-areas para la sección “Sobre mí”
Un título fluidoEscala tipográfica fluida completa con variables
Transición en el botónAnimación de entrada y tarjetas que reaccionan al ratón
Cabecera normalCabecera pegajosa con fondo translúcido

Planificar antes de escribir

Antes de abrir el editor, haz un boceto en papel con las secciones y cómo se colocan en móvil y en pantalla ancha:

  1. Cabecera fija arriba: nombre a la izquierda; menú y botón de tema a la derecha.
  2. Héroe: saludo, una frase que diga a qué te dedicas y dos botones (ver proyectos y descargar CV).
  3. Sobre mí: foto, texto y una lista de tecnologías. En móvil, uno debajo de otro; en pantalla ancha, foto a la izquierda y texto a la derecha.
  4. Proyectos: tarjetas con imagen, título, descripción, etiquetas y enlaces.
  5. Contacto: una frase y enlaces a correo, GitHub y LinkedIn.
  6. Pie.

Decide también la paleta: un fondo, una superficie (para las tarjetas), el color del texto, un gris para textos secundarios y un color de acento (el que destaca: botones, enlaces). Para cada uno necesitas su versión clara y su versión oscura. Aquí usaremos un verde azulado.

Crea una carpeta portfolio con index.html, estilos.css, tema.js y una carpeta img para las capturas de tus proyectos.

Paso 1: el HTML

Igual que en cualquier maquetación, primero el HTML semántico completo. Cada sección tiene un id para que el menú pueda enlazar a ella.

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Marta Ruiz | Desarrolladora web</title>
  <meta name="description" content="Portfolio de Marta Ruiz, desarrolladora web junior en Santander.">
  <link rel="stylesheet" href="estilos.css">
  <script src="tema.js" defer></script>
</head>
<body>
  <header class="cabecera">
    <div class="contenedor cabecera__interior">
      <a class="logo" href="#inicio">marta.dev</a>
      <nav class="menu" aria-label="Principal">
        <a href="#sobre-mi">Sobre mí</a>
        <a href="#proyectos">Proyectos</a>
        <a href="#contacto">Contacto</a>
      </nav>
      <button class="boton-tema" id="boton-tema" type="button" aria-label="Cambiar tema">◐</button>
    </div>
  </header>

  <main id="inicio">
    <section class="heroe contenedor">
      <p class="heroe__saludo">Hola, soy Marta Ruiz</p>
      <h1 class="heroe__titulo">Desarrollo webs rápidas, accesibles y fáciles de usar</h1>
      <p class="heroe__texto">Estudio DAW y busco empresa para hacer mis prácticas.</p>
      <div class="heroe__botones">
        <a class="boton" href="#proyectos">Ver proyectos</a>
        <a class="boton boton--borde" href="cv-marta-ruiz.pdf" download>Descargar CV</a>
      </div>
    </section>

    <section class="seccion contenedor" id="sobre-mi">
      <h2 class="seccion__titulo">Sobre mí</h2>
      <div class="sobre-mi">
        <img class="sobre-mi__foto" src="img/marta.jpg" alt="Marta sonriendo delante de un portátil"
             width="400" height="400">
        <div class="sobre-mi__texto">
          <p>Empecé a programar haciendo la web del club de senderismo de mi pueblo.
             Me gusta que las páginas carguen rápido y que cualquiera pueda usarlas.</p>
          <p>Ahora estoy aprendiendo TypeScript y bases de datos.</p>
        </div>
        <ul class="sobre-mi__tecnologias etiquetas">
          <li>HTML</li><li>CSS</li><li>JavaScript</li><li>Git</li><li>Astro</li><li>SQL</li>
        </ul>
      </div>
    </section>

    <section class="seccion contenedor" id="proyectos">
      <h2 class="seccion__titulo">Proyectos</h2>
      <div class="proyectos">
        <article class="proyecto">
          <img class="proyecto__imagen" src="img/pistas.jpg" alt="" width="640" height="400">
          <div class="proyecto__cuerpo">
            <h3 class="proyecto__titulo">Reservas de pistas</h3>
            <p>App para reservar pistas de pádel del polideportivo municipal.</p>
            <ul class="etiquetas"><li>HTML</li><li>CSS</li><li>JavaScript</li></ul>
            <div class="proyecto__enlaces">
              <a href="#">Ver web</a>
              <a href="#">Código</a>
            </div>
          </div>
        </article>
        <!-- Repite <article class="proyecto"> para cada proyecto -->
      </div>
    </section>

    <section class="seccion contenedor contacto" id="contacto">
      <h2 class="seccion__titulo">¿Hablamos?</h2>
      <p>Escríbeme si buscas a alguien para prácticas o un proyecto pequeño.</p>
      <a class="boton" href="mailto:hola@marta.dev">hola@marta.dev</a>
      <ul class="redes">
        <li><a href="https://github.com/">GitHub</a></li>
        <li><a href="https://www.linkedin.com/">LinkedIn</a></li>
      </ul>
    </section>
  </main>

  <footer class="pie">
    <p>© 2026 Marta Ruiz · Hecho a mano con HTML y CSS</p>
  </footer>
</body>
</html>

Fíjate en dos detalles. La imagen de cada proyecto tiene alt="" porque el título de la tarjeta ya dice qué es; repetirlo solo haría que el lector de pantalla lo leyera dos veces. Y el script se carga con defer en el <head>: así se descarga pronto pero se ejecuta cuando el HTML ya está listo.

Paso 2: los colores de los dos temas

En lugar de definir los colores dos veces, usa la función light-dark(claro, oscuro): devuelve el primer valor si el tema es claro y el segundo si es oscuro. Para que funcione, el elemento debe declarar color-scheme: light dark. Lo tienes explicado con calma en Modo oscuro.

/* ---------- Tema ---------- */
:root {
  color-scheme: light dark;

  --fondo: light-dark(#fbfaf7, #14161b);
  --superficie: light-dark(#ffffff, #1f2229);
  --texto: light-dark(#1d1f24, #e8e8ea);
  --suave: light-dark(#5d6370, #a5abb6);
  --acento: light-dark(#0f766e, #5eead4);
  --borde: light-dark(#e3e1da, #2e323b);
  --sombra: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.5));
}

/* Si el usuario elige un tema con el botón, se impone al del sistema */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }

Sin hacer nada más, la página ya sigue la preferencia del sistema operativo. El atributo data-theme, que pondrá el JavaScript, permite forzar un tema concreto.

Consejo: comprueba el contraste del acento en los dos temas. Un verde que se lee bien sobre blanco suele ser demasiado oscuro sobre negro; por eso el oscuro usa un tono más claro (#5eead4).

Paso 3: tipografía fluida y espacios

En vez de un único título fluido, define una escala tipográfica completa: unas pocas variables de tamaño que crecen con la pantalla gracias a clamp(mínimo, preferido, máximo). Haz lo mismo con los espacios. Así todo el diseño respira más en pantallas grandes sin escribir media queries.

:root {
  /* Tamaños de letra */
  --texto-sm: clamp(0.85rem, 0.8rem + 0.2vw, 0.95rem);
  --texto-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --texto-lg: clamp(1.25rem, 1.1rem + 0.6vw, 1.6rem);
  --texto-xl: clamp(2rem, 1.3rem + 3.5vw, 3.75rem);

  /* Espacios */
  --espacio-s: clamp(0.5rem, 0.4rem + 0.5vw, 0.75rem);
  --espacio-m: clamp(1rem, 0.8rem + 1vw, 1.5rem);
  --espacio-l: clamp(2.5rem, 1.5rem + 5vw, 6rem);

  --radio: 12px;
  --ancho: 1100px;
}

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

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  font-size: var(--texto-base);
  line-height: 1.6;
  background: var(--fondo);
  color: var(--texto);
}

h1, h2, h3 { line-height: 1.15; text-wrap: balance; }
img { display: block; max-width: 100%; height: auto; }
a { color: var(--acento); }

.contenedor {
  width: min(100% - 2 * var(--espacio-m), var(--ancho));
  margin-inline: auto;
}

.seccion { padding-block: var(--espacio-l); }
.seccion__titulo { font-size: var(--texto-lg); margin: 0 0 var(--espacio-m); }

Los tamaños mínimos están en rem para que respeten el tamaño de letra que el usuario haya elegido en su navegador. text-wrap: balance reparte las palabras de los títulos en líneas de longitud parecida, para que no quede una palabra sola en la última línea.

Paso 4: cabecera pegajosa

La cabecera se queda pegada arriba al hacer scroll con position: sticky. Le damos un fondo semitransparente con desenfoque para que el contenido se intuya por debajo. Para la fila interior, Flexbox.

/* ---------- Cabecera ---------- */
.cabecera {
  position: sticky;
  top: 0;
  z-index: 10;
  background: color-mix(in srgb, var(--fondo) 85%, transparent);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--borde);
}

.cabecera__interior {
  display: flex;
  align-items: center;
  gap: var(--espacio-m);
  padding-block: var(--espacio-s);
}

.logo {
  margin-right: auto;          /* empuja el resto a la derecha */
  color: var(--texto);
  font-weight: 700;
  text-decoration: none;
}

.menu { display: none; gap: var(--espacio-m); }
.menu a { color: var(--suave); text-decoration: none; }
.menu a:hover, .menu a:focus-visible { color: var(--acento); }

.boton-tema {
  width: 44px;
  height: 44px;
  border: 1px solid var(--borde);
  border-radius: 50%;
  background: var(--superficie);
  color: var(--texto);
  font-size: 1.2rem;
  cursor: pointer;
}

@media (width >= 640px) {
  .menu { display: flex; }
}

/* Que los títulos no queden tapados por la cabecera al saltar a un ancla */
html { scroll-behavior: smooth; }
section[id] { scroll-margin-top: 80px; }

margin-right: auto en el logo es un truco clásico de Flexbox: el margen automático se come todo el espacio sobrante y empuja los demás elementos al otro extremo. color-mix() mezcla el color de fondo con transparente para conseguir el 85 % de opacidad sin escribir otro color. Más sobre sticky y scroll-margin-top en Scroll.

Nota: en móvil ocultamos el menú para simplificar. En un portfolio de una sola página basta con hacer scroll; si quieres un menú desplegable, lo puedes construir con <details> o con JavaScript.

Paso 5: el héroe con animación de entrada

/* ---------- Héroe ---------- */
.heroe { padding-block: var(--espacio-l); }

.heroe__saludo {
  color: var(--acento);
  font-weight: 600;
  margin: 0;
}

.heroe__titulo {
  font-size: var(--texto-xl);
  max-width: 20ch;
  margin: var(--espacio-s) 0 var(--espacio-m);
}

.heroe__texto { color: var(--suave); max-width: 50ch; }

.heroe__botones {
  display: flex;
  flex-wrap: wrap;
  gap: var(--espacio-s);
  margin-top: var(--espacio-m);
}

/* Animación de entrada escalonada */
.heroe > * {
  animation: aparecer 0.7s ease-out both;
}
.heroe > :nth-child(2) { animation-delay: 0.1s; }
.heroe > :nth-child(3) { animation-delay: 0.2s; }
.heroe > :nth-child(4) { animation-delay: 0.3s; }

@keyframes aparecer {
  from {
    opacity: 0;
    translate: 0 16px;
  }
}

Tres ideas importantes aquí:

  • max-width: 20ch limita el título a unos 20 caracteres por línea. La unidad ch equivale al ancho del carácter “0”, así que es perfecta para controlar la longitud de las líneas de texto.
  • En @keyframes solo escribimos from: el navegador usa como to el estado normal del elemento. Así no repites los valores finales.
  • both (en animation-fill-mode) hace que, durante el retraso, el elemento ya esté en el estado from. Sin él, verías el texto un instante, desaparecería y luego se animaría.

Animamos solo opacity y translate porque el navegador los mueve sin recalcular el diseño de la página. Todo esto está explicado en Animaciones.

Paso 6: botones

/* ---------- Botones ---------- */
.boton {
  display: inline-block;
  padding: 0.7em 1.4em;
  border: 2px solid var(--acento);
  border-radius: var(--radio);
  background: var(--acento);
  color: var(--fondo);
  font-weight: 600;
  text-decoration: none;
  transition: background-color 0.2s, color 0.2s, translate 0.2s;
}

.boton:hover { translate: 0 -2px; }

.boton--borde {
  background: transparent;
  color: var(--acento);
}

.boton--borde:hover {
  background: var(--acento);
  color: var(--fondo);
}

:focus-visible {
  outline: 3px solid var(--acento);
  outline-offset: 3px;
}

El texto del botón usa var(--fondo) como color: en tema claro será casi blanco y en oscuro casi negro, siempre con buen contraste sobre el acento. Un único :focus-visible global da a todos los enlaces y botones un contorno visible al navegar con teclado.

Paso 7: “Sobre mí” con áreas de Grid

Esta sección tiene tres piezas: foto, texto y lista de tecnologías. En móvil van una debajo de otra. En pantalla ancha, la foto ocupa una columna a la izquierda y las otras dos se apilan a su derecha. Con grid-template-areas dibujas esa distribución con nombres, casi como un mapa:

/* ---------- Sobre mí ---------- */
.sobre-mi {
  display: grid;
  gap: var(--espacio-m);
  grid-template-areas:
    "foto"
    "texto"
    "tecnologias";
}

.sobre-mi__foto { grid-area: foto; }
.sobre-mi__texto { grid-area: texto; }
.sobre-mi__tecnologias { grid-area: tecnologias; }

.sobre-mi__foto {
  width: min(100%, 280px);
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 50%;
}

.sobre-mi__texto p { margin-top: 0; }

@media (width >= 760px) {
  .sobre-mi {
    grid-template-columns: 280px 1fr;
    grid-template-areas:
      "foto texto"
      "foto tecnologias";
    align-items: start;
  }
}

Lo bueno de las áreas es que, para cambiar la distribución, solo reescribes el “mapa” dentro de la media query; los elementos no se tocan. Repasa la sintaxis en Grid.

Paso 8: las etiquetas de tecnologías

Las etiquetas (las “píldoras” con HTML, CSS…) aparecen en “Sobre mí” y en cada proyecto, así que son un componente reutilizable:

/* ---------- Etiquetas ---------- */
.etiquetas {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.etiquetas li {
  font-size: var(--texto-sm);
  padding: 2px 12px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--acento) 15%, transparent);
  color: var(--texto);
}

flex-wrap: wrap hace que las etiquetas bajen de línea cuando no caben. border-radius: 999px es la forma habitual de conseguir extremos totalmente redondos sea cual sea la altura.

Paso 9: la rejilla de proyectos

Aquí no queremos decidir a mano cuántas columnas hay. Con auto-fit y minmax(), Grid mete tantas columnas de al menos 280px como quepan y reparte el espacio sobrante:

/* ---------- Proyectos ---------- */
.proyectos {
  display: grid;
  gap: var(--espacio-m);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
}

.proyecto {
  display: flex;
  flex-direction: column;
  background: var(--superficie);
  border: 1px solid var(--borde);
  border-radius: var(--radio);
  overflow: hidden;            /* recorta la imagen a las esquinas redondeadas */
  transition: transform 0.25s ease, box-shadow 0.25s ease;
}

.proyecto:hover {
  transform: translateY(-6px);
  box-shadow: 0 12px 28px var(--sombra);
}

.proyecto__imagen {
  aspect-ratio: 16 / 10;
  width: 100%;
  object-fit: cover;
}

.proyecto__cuerpo {
  display: flex;
  flex-direction: column;
  gap: var(--espacio-s);
  flex: 1;                     /* el cuerpo ocupa todo el alto disponible */
  padding: var(--espacio-m);
}

.proyecto__titulo { font-size: var(--texto-lg); margin: 0; }
.proyecto__cuerpo p { margin: 0; color: var(--suave); }

.proyecto__enlaces {
  display: flex;
  gap: var(--espacio-m);
  margin-top: auto;            /* empuja los enlaces al fondo de la tarjeta */
}

¿Por qué min(100%, 280px) y no 280px a secas? En un móvil de 320px, con los márgenes, el contenedor mide menos de 280px; una columna de 280px se saldría y provocaría scroll horizontal. Con min(), la columna nunca es más ancha que su contenedor.

La tarjeta es un Flexbox en columna y su cuerpo tiene flex: 1. Como Grid estira todas las tarjetas de una fila a la misma altura, el margin-top: auto de los enlaces los manda abajo del todo: quedan alineados aunque una descripción sea más larga que otra. Los enlaces van en un <div> y no en un <p> a propósito: si fuera un párrafo, la regla .proyecto__cuerpo p { margin: 0 }, que tiene más especificidad, anularía ese margin-top: auto.

Paso 10: contacto y pie

/* ---------- Contacto ---------- */
.contacto { text-align: center; }
.contacto p { color: var(--suave); }

.redes {
  list-style: none;
  padding: 0;
  display: flex;
  justify-content: center;
  gap: var(--espacio-m);
  margin-top: var(--espacio-m);
}

/* ---------- Pie ---------- */
.pie {
  border-top: 1px solid var(--borde);
  padding-block: var(--espacio-m);
  text-align: center;
  font-size: var(--texto-sm);
  color: var(--suave);
}

Paso 11: respetar a quien no quiere movimiento

Algunas personas se marean con las animaciones y lo indican en su sistema operativo. La media query prefers-reduced-motion te lo dice. Pon esto al final del CSS:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  html { scroll-behavior: auto; }
}

Usamos una duración casi nula en lugar de animation: none para que los elementos lleguen igualmente a su estado final. Es uno de los pocos casos en que !important está justificado. Más en CSS accesible.

Paso 12: el botón de tema

El CSS ya hace casi todo. Solo falta un poco de JavaScript que ponga data-theme en <html> y lo recuerde para la próxima visita con localStorage:

// tema.js
const raiz = document.documentElement;
const guardado = localStorage.getItem('tema');
if (guardado) raiz.dataset.theme = guardado;

document.addEventListener('DOMContentLoaded', () => {
  document.querySelector('#boton-tema').addEventListener('click', () => {
    const sistemaOscuro = matchMedia('(prefers-color-scheme: dark)').matches;
    const actual = raiz.dataset.theme ?? (sistemaOscuro ? 'dark' : 'light');
    const nuevo = actual === 'dark' ? 'light' : 'dark';
    raiz.dataset.theme = nuevo;
    localStorage.setItem('tema', nuevo);
  });
});

Si nunca se ha pulsado el botón, no hay data-theme y manda el sistema. Al pulsarlo, se calcula el tema actual y se cambia al contrario. Si aún no has llegado a JavaScript, no pasa nada: sin este archivo la página sigue funcionando y se adapta sola al tema del sistema.

Probarlo y publicarlo

Pegando en orden los bloques CSS de los pasos 2 a 11 tienes el estilos.css completo. Revisa:

  • En DevTools, modo dispositivo, a 320px, 768px y 1280px: sin scroll horizontal y con la rejilla de proyectos cambiando de columnas sola.
  • En la pestaña Rendering de DevTools puedes emular prefers-color-scheme: dark y prefers-reduced-motion: reduce sin tocar tu sistema.
  • Navega solo con el tabulador: cada enlace y botón debe mostrar su contorno.

Cuando te guste, súbelo gratis a GitHub Pages, Netlify o Cloudflare Pages y pon el enlace en tu CV.

Ideas para ampliarlo

  • Filtros de proyectos: botones “Todos”, “CSS”, “JavaScript” que oculten tarjetas. Con CSS puro puedes hacerlo con casillas y :has(); con JavaScript, añadiendo una clase.
  • Animación al hacer scroll: que cada tarjeta aparezca al entrar en pantalla con animation-timeline: view() (mira Animaciones).
  • Línea de tiempo con tu formación y experiencia, usando un pseudoelemento ::before como línea vertical.
  • Tarjetas con container queries: que la tarjeta cambie de vertical a horizontal según su propio ancho (Container queries).
  • Hoja de impresión: que al imprimir la página salga como un CV limpio (CSS para imprimir).

Errores frecuentes

  • Poner columnas fijas en la rejilla (repeat(3, 280px)). En móvil desbordan. Usa auto-fit con minmax(min(100%, 280px), 1fr).
  • Olvidar color-scheme. Sin él, light-dark() siempre devuelve el valor claro y los controles del navegador (barras de scroll, campos) no cambian de tema.
  • Comprobar el contraste solo en un tema. El acento que funciona en claro puede no leerse en oscuro.
  • Animar top, margin o height. Obligan a recalcular el diseño y van a tirones. Anima transform, translate y opacity.
  • Animaciones sin prefers-reduced-motion. Para algunas personas no son un adorno, son una molestia física.
  • Olvidar scroll-margin-top con una cabecera fija. Al pulsar en el menú, el título de la sección queda escondido detrás de la cabecera.
  • Llenarlo de proyectos de tutorial. Mejor tres proyectos propios bien explicados que diez copias.

Resumen

ParteTécnica principal
Tema claro y oscurocolor-scheme, light-dark() y data-theme
Tipografía y espaciosEscala de variables con clamp()
Cabeceraposition: sticky, backdrop-filter, Flexbox con margin-right: auto
Héroe@keyframes con from, retrasos escalonados, ch
Sobre míGrid con grid-template-areas que cambia en una media query
Proyectosrepeat(auto-fit, minmax(min(100%, 280px), 1fr))
TarjetasFlexbox en columna y margin-top: auto
Accesibilidad:focus-visible, prefers-reduced-motion

Para poner a prueba todo lo aprendido, sigue con los Ejercicios de CSS.

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

proyecto-portfolio.html
Resultado

Pon a prueba lo que has aprendido

[CSS] ¿Por qué la rejilla de proyectos usa minmax(min(100%, 280px), 1fr) en lugar de minmax(280px, 1fr)?
.proyectos {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
}

[CSS] Has definido los colores con light-dark(), pero la página siempre se ve en tema claro aunque el sistema esté en oscuro. ¿Qué falta?
:root {
  --fondo: light-dark(#fbfaf7, #14161b);
  --texto: light-dark(#1d1f24, #e8e8ea);
}

[CSS] En una tarjeta con display: flex y flex-direction: column, ¿qué consigue margin-top: auto en el bloque de enlaces, que es su último hijo?

[CSS] ¿Para qué sirve el valor both en esta animación de entrada con retraso?
.heroe__titulo {
  animation: aparecer 0.7s ease-out 0.2s both;
}
@keyframes aparecer {
  from { opacity: 0; translate: 0 16px; }
}

[CSS] La cabecera es position: sticky. Al pulsar un enlace del menú a #proyectos, el título de la sección queda tapado por la cabecera. ¿Cómo se arregla con CSS?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.