Antes de empezar

Metodología: el papel de la IA en este proyecto

Este proyecto se ha construido con IA de principio a fin. Esta sección explica por qué, con qué papel exacto, y dónde se puede rastrear ese papel en el resto de esta Memoria.

Por qué

No como sustituto de aprender, sino como método para abordar un proyecto de nivel profesional en el tiempo del curso.

Viniendo de administración y contabilidad, sin bagaje previo de programación, construir un portfolio con arquitectura propia, SEO/AEO, cumplimiento legal y una migración de hosting real —además del propio contenido del curso— habría sido inviable en solitario en el marco de tiempo disponible. La motivación de trabajar con IA no fue evitar programar, sino poder construir algo real mientras se aprendía, en vez de limitarse a un ejercicio de alcance reducido.

Qué papel exacto

Decisiones compartidas en diseño, arquitectura y contenido; código generado por la IA bajo revisión propia.

En diseño, arquitectura y contenido —el árbol de navegación de la Fase 2, la paleta y tipografía de la Fase 3, la estructura de esta misma Memoria— cada decisión fue compartida: el problema se planteaba, se valoraban opciones juntos, y la elección final, con su criterio, era propia. En el código, sobre todo en PHP —fuera del temario del curso, como se explica en la Fase 4—, la IA generó gran parte de los ficheros; el papel propio fue revisar, depurar, y aprender de cada pieza generada, no escribirla línea a línea desde cero.

Esta distinción se puede rastrear por el resto de esta Memoria: cualquier mención a "con apoyo de Claude" —en la Fase 2 (SEO/AEO), en la Fase 5 (migración de hosting), en la Fase 6 (aprendizajes)— se refiere específicamente a este segundo tipo de colaboración, no al primero.

Fase 01

Punto de partida

El curso fijó un marco mínimo de requisitos. Esta fase distingue qué vino dado por el enunciado y qué se construyó después, a partir de ahí.

Requisitos de partida (dados por el curso)

El curso solo pedía un proyecto WordPress genérico, de cualquier temática de negocio, con una defensa oral de apoyo.

El enunciado pedía un proyecto WordPress genérico: cualquier temática de negocio era válida. Como mínimo técnico exigía páginas, entradas y categorías en WordPress, el desarrollo de los archivos de plantilla de la jerarquía de WordPress (header.php, index.php, category.php, single.php, page.php...), reglas CSS con variables y consultas de medios, criterios SEO, y una defensa oral de 5 a 15 minutos el 30 de julio con algún soporte gráfico o audiovisual — el propio enunciado sugería, como ejemplo, un documento, PowerPoint o PDF.

El enfoque construido a partir de ahí

El portfolio en sí, los filtros por tecnología, las fichas con evolución, y esta misma web.

Convertir el proyecto en un portfolio profesional propio en vez de un negocio ficticio, estructurar el contenido como un Custom Post Type de actividades filtrable por tecnología —un tipo de contenido propio de WordPress, distinto de las páginas o entradas estándar—, dar a cada actividad una ficha individual con su evolución documentada, y construir esta segunda web completa como soporte de la defensa en lugar de un PowerPoint o PDF.

Dentro de esa web propia, la accesibilidad del color se verificó además contra el estándar WCAG nivel AA en toda la interfaz.

El filtrado por tecnología consulta la base de datos en tiempo real mediante AJAX.

Cliente, público y referencias

Sin cliente ni competencia reales, el ejercicio se traslada: el cliente soy yo, el público es quien contrata.

El enunciado planteaba estos tres puntos pensando en un negocio ficticio, pero en un portfolio profesional se trasladan de forma directa, sin necesidad de simular un proceso que no existió:

Cliente: en este proyecto el cliente soy yo mismo como marca profesional. Mis necesidades como cliente son las de cualquier persona en transición de carrera: demostrar competencia técnica real, mostrar un criterio de diseño propio y no genérico, y resultar creíble y localizable para quien contrata.

Público objetivo: no son consumidores finales, sino reclutadores técnicos y responsables de contratación. Varias decisiones ya tomadas respondían a este público sin haberlo formalizado antes — por ejemplo, mencionar en Sobre mí que usé a Claude como compañero de proceso es una decisión de transparencia pensada directamente para un evaluador técnico.

User personas: dentro de ese público objetivo hay dos perfiles con criterios de evaluación distintos. Persona A — responsable de RRHH o administración, sin formación técnica profunda: valora claridad, profesionalidad y ausencia de señales de inestabilidad laboral (de ahí la cautela con expresiones como "en transición" en el posicionamiento dual-track). Persona B — technical lead o desarrollador frontend: evalúa el código en sí, la arquitectura, y si cada decisión técnica se puede defender bajo preguntas — el criterio que ha guiado buena parte del trabajo de esta Memoria y de la propia defensa oral.

Referencias de mercado: no se hizo un benchmarking formal con tabla comparativa, pero sí hubo un ejercicio real de referencia: evitar activamente patrones de diseño genérico observados en portfolios hechos con IA (por ejemplo, el descarte explícito de la paleta terracota por su parecido con la identidad de marca de Anthropic).

Fase 02

Arquitectura y planificación

Antes de tocar diseño visual, había que decidir cómo se organiza y se navega el contenido. Esta fase resuelve el qué va dónde y el cómo se llega, con las mismas alternativas descartadas y el motivo detrás de cada una.

Árbol de navegación

Las tecnologías se navegan como filtros, no como menú — y el proyecto final no es una sección más, es el sitio entero.

El árbol de navegación —el esquema que define qué páginas existen y cómo se relacionan entre sí— quedó en cuatro secciones principales: Inicio, Proyectos/Actividades, Sobre mí y Contacto.

Categorías de tecnología como filtros, no como menú desplegable. Las tecnologías (Figma, HTML, CSS, JS, Bootstrap...) no aparecen como un submenú desplegable desde la navegación principal, sino como filtros dentro de la propia página de Proyectos/Actividades. Un desplegable obliga a decidir la tecnología antes de ver ningún resultado; un filtro in-page permite ver todo primero y acotar después, con menos fricción de navegación y sin multiplicar el número de páginas del árbol.

El proyecto final no es un ítem de menú. Aunque el enunciado pedía dar centralidad al proyecto final, no tiene sentido como nodo del árbol de navegación porque no es "una sección más" — es el sitio completo. Esa centralidad se resuelve en su lugar con un mensaje destacado en Inicio y un CTA visible hacia esta misma web de proceso.

Multi-categoría permitida. Una actividad puede pertenecer a varias tecnologías a la vez (por ejemplo, una pieza hecha con HTML, CSS y JS junta), en vez de forzar una única categoría — refleja mejor cómo es el trabajo real, que rara vez usa una sola tecnología de forma aislada.

Wireframes

Mobile-first en 5 páginas y 3 breakpoints, priorizando legibilidad y facilidad táctil sobre densidad de contenido.

Se trabajó mobile-first —diseñando primero la versión más pequeña y expandiendo después— sobre 5 páginas y 3 breakpoints (375 / 768 / 1440px), antes de pasar a ningún prototipo de alta fidelidad.

"Evolución y versiones": tarjetas horizontales en tablet/escritorio, apiladas con imagen completa en mobile. En pantallas grandes, mostrar los pasos en horizontal favorece la escaneabilidad —recorrer varios pasos de un vistazo—, algo que tiene sentido con 4-5 pasos visibles a la vez. En mobile esa misma disposición obligaría a hacer scroll horizontal o encoger demasiado cada tarjeta, así que se apilan en vertical con la imagen a tamaño completo, priorizando que sea fácil de tocar y de leer sobre la cantidad de contenido visible de golpe.

Animaciones de entrada sutiles, respetando prefers-reduced-motion. Se añadieron pequeñas animaciones de aparición al hacer scroll para dar sensación de cuidado en el detalle, pero condicionadas a que el sistema operativo de quien visita la web no tenga activada la reducción de movimiento — una preferencia de accesibilidad real que hay que respetar, no solo un capricho técnico.

Datos estructurados y SEO/AEO

El contenido también se organiza pensando en quién lo lee sin ser una persona: buscadores y asistentes de IA.

Además del árbol de navegación pensado para personas, se trabajó una capa paralela pensada para motores de búsqueda y asistentes de IA — con Rank Math gestionando el Schema de tipo Person, enlazado mediante sameAs a los cuatro perfiles reales (GitHub, LinkedIn, Instagram, CodePen), verificación en Google Search Console, y un archivo llms.txt en la raíz del dominio.

Visibilidad total, sin filtrar. Se decidió enlazar los cuatro perfiles sociales sin criterio de selección — no se trataba de elegir qué mostrar y qué ocultar, sino de que cualquier buscador o asistente que consultara el proyecto tuviera acceso al cuadro completo. La investigación del estándar (formato de llms.txt, estructura del Schema Person) se hizo con apoyo de Claude; la decisión de qué perfiles enlazar y con qué alcance, propia.

Fase 03

Decisiones de diseño UX

Cada elemento visual del portfolio sigue el mismo esquema: qué se eligió, qué alternativa se descartó, y qué principio de diseño lo sustenta — no gusto personal, sino criterio justificable.

Color

Burdeos y crema en vez de terracota — para que el portfolio no se pareciera a la identidad de marca de una IA.

Elegido: paleta cálida crema (#FAF6F0) y burdeos (#7A2E33) como color de acento.

Descartado: una gama terracota/naranja, barajada en las primeras pruebas.

Principio: como no llevar la misma camiseta que el anfitrión de una fiesta para que nadie te confunda con él — diferenciación de marca: evitar que el portfolio se percibiera como generado o inspirado en la identidad visual de una marca de IA ajena (Anthropic/Claude), reforzando que el proyecto es un producto propio y original. Además, se verificó el ratio de contraste entre texto y fondo en toda la interfaz.

Tipografía

Fraunces aporta personalidad; Inter garantiza legibilidad — un pairing deliberado, no una tipografía única.

Elegido: Fraunces para titulares, Inter para cuerpo de texto.

Descartado: usar una única tipografía sans geométrica para todo el sitio, titulares incluidos.

Principio: como combinar un traje elegante con una camisa cómoda debajo —cada pieza cumple un papel distinto, y juntas transmiten algo que ninguna lograría sola—: pairing tipográfico, crear jerarquía y contraste sin depender solo del tamaño, combinado con legibilidad en pantalla (Inter está optimizada para interfaces digitales) y personalidad de marca: una serif con carácter artesanal como Fraunces distingue el proyecto de cualquier plantilla genérica, mientras que una única sans geométrica no lo haría.

Jerarquía visual

El h2 baja de 36 a 24px para que ningún titular de sección compita con el del hero.

Elegido: tamaño de h2 de sección en 24px; hero de Inicio a sangre completa sin radio de borde; botón "Cargar más" en estilo outline/secundario.

Descartado: h2 a 36px (la escala inicial del sistema tipográfico); radio de borde en el hero; "Cargar más" con el mismo relleno burdeos que la acción principal.

Principio: en una foto de grupo, si todos gritan a la vez para salir en primer plano, nadie destaca de verdad — jerarquía visual de énfasis único: un solo elemento debe ser el protagonista por pantalla. Un h2 a 36px competía con el h1 del hero en vez de subordinarse a él; el viewport ya actúa de marco natural para el hero, así que un radio de borde adicional compite con ese marco en lugar de reforzarlo; y un botón secundario con el mismo peso visual que el primario diluye cuál es la acción que de verdad importa.

Iconografía

Todos los iconos siguen la misma familia outline — incluso el que tuvo que buscarse a mano.

Elegido: Bootstrap Icons, estilo outline, en toda la interfaz; iconos sociales del footer en #6E6358.

Descartado: mezclar estilos (outline + relleno) según disponibilidad; iconos sociales en #E4DCD2, el tono inicial.

Principio: como una vajilla a juego —aunque cada plato sea distinto, todos comparten el mismo estilo y no se nota que falta una pieza comprada aparte—: consistencia de familia. Un sistema de iconos coherente refuerza que el proyecto tiene un lenguaje visual propio, no piezas sueltas de sitios distintos. CodePen no existe como icono en la librería outline, así que se buscó e insertó un SVG propio en el mismo estilo, en vez de romper la consistencia usando un icono relleno solo porque era el que había disponible. El tono #E4DCD2 de los iconos sociales no pasaba el ratio de contraste mínimo sobre fondo blanco, así que se sustituyó por #6E6358 —el marrón ya usado en el footer—, que además evita competir visualmente con el CTA burdeos de esa misma zona.

Imágenes

La imagen de cada actividad va con esquinas redondeadas y el título debajo — nunca superpuesto.

Elegido: en la ficha individual de cada actividad, la imagen va insertada con esquinas redondeadas y el título en texto oscuro justo debajo.

Descartado: título superpuesto sobre la imagen con un overlay (velo) blanco.

Principio: como poner los subtítulos siempre en una franja fija en vez de escritos directamente sobre la imagen de fondo, para que se lean igual de bien sea cual sea la escena — claridad tipográfica: cada actividad tiene una imagen distinta, y un texto superpuesto arriesga legibilidad si el contraste de esa imagen concreta no acompaña. Separar imagen y título garantiza legibilidad constante sin depender del contenido de cada foto. El mismo criterio de consistencia se aplica al aspect ratio de las imágenes en todo el sitio, y a usar la secuencia de "Evolución y versiones" como recurso de storytelling visual, no solo como galería.

Fase 04

Justificación técnica

No basta con que el código funcione — hay que poder explicar por qué está escrito así y qué hace cada pieza por dentro. Cada subapartado cubre ambas capas: la decisión de arquitectura y la lógica de programación (condicionales, arrays, funciones, consultas a la base de datos).

HTML

<article> para lo que tiene sentido por sí solo, <aside> para lo relacionado pero no esencial — decidido antes de escribir una sola línea de CSS.

Jerarquía de encabezados (h1, h2, h3). Cada página tiene un único <h1> —el título de esa página: "Marc Borrell", "Proyectos / Actividades", el título de cada actividad—, y los <h2>/<h3> descienden en el mismo orden que el documento, no por el tamaño que se les quiera dar. Ese orden es lo que un lector de pantalla usa para saltar de sección en sección sin leer todo el texto intermedio: si un <h3> apareciera antes que su <h2> "padre", ese salto dejaría de tener sentido. El tamaño visual de cada nivel se controla aparte, en CSS —la escala 20/22/24px que vimos en la Fase 3—: la jerarquía semántica (qué etiqueta se usa) y la jerarquía visual (qué tamaño se le da) son decisiones independientes, aunque normalmente vayan de la mano.

<div> como contenedor genérico. A diferencia de <article>, <aside> o <nav>, un <div> no comunica ningún significado — se usa exactamente donde no hace falta ninguno, casi siempre por motivos de maquetación: .container centra el contenido y limita su ancho máximo, .actividades-grid aplica la cuadrícula de CSS Grid a sus hijos. Usarlo ahí, en vez de forzar una etiqueta semántica sin necesidad real, evita el error contrario a no usar suficiente semántica: la semántica falsa —por ejemplo, envolver un simple contenedor de maquetación en un <section> solo por costumbre, cuando ese bloque no representa ninguna sección de contenido real—.

single-actividad.php

<article class="actividad-single">
    <header class="actividad-single__hero">
        ...
    </header>

    <div class="container actividad-single__body">
        <div class="actividad-single__principal">...</div>

        <aside class="actividad-single__ficha">
            <h2>Conceptos clave</h2>
            ...
        </aside>
    </div>

    <nav class="actividad-nav container" aria-label="Navegación entre actividades">
        ...
    </nav>
</article>

La ficha de cada actividad usa <article> porque el contenido tiene sentido por sí solo, fuera de contexto —igual que un post de blog es <article> aunque se lea embebido en un listado—. El bloque "Conceptos clave" es <aside> porque es información relacionada pero no forma parte del flujo principal de lectura: alguien puede saltárselo sin perder el hilo de la actividad.

El <nav> de "actividad anterior / siguiente" lleva un aria-label explícito porque la página ya tiene otro <nav> —el menú principal del header—. Sin esa etiqueta, alguien navegando con lector de pantalla por landmarks (regiones de la página) solo vería dos elementos llamados igual, "navegación", sin forma de distinguir cuál es cuál antes de entrar en cada uno.

El resto de atributos ARIA del proyecto —aria-expanded en el menú hamburguesa, aria-controls, role="dialog" y aria-modal en el modal de imagen, role="alert" y role="status" en los mensajes del formulario— cada uno hace algo distinto en el código, pero todos resuelven el mismo problema de fondo: hay información que se comunica visualmente (un menú está abierto, un mensaje es un error, una imagen se ha ampliado) y que, sin ARIA, solo llega a quien puede ver la pantalla. Estos atributos hacen que esa misma información esté disponible para cualquier tecnología de asistencia —lectores de pantalla, navegación solo con teclado, o cualquier otro sistema que dependa de algo más que el aspecto visual—, no únicamente para un colectivo concreto.

page-contacto.php

<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="tu@email.com" required>
<span class="contacto__error" data-error-for="email" role="alert"></span>

Etiquetas de formulario enlazadas (label for + id). El atributo for del <label> coincide exactamente con el id del campo. Esto no es solo semántica: hace que tocar o hacer clic en la palabra "Email" active el campo como si se tocara directamente, y que un lector de pantalla anuncie el nombre del campo antes de su contenido.

Tipo de input específico (type="email"). En vez de un type="text" genérico, declarar el tipo correcto hace que el móvil muestre un teclado adaptado (con la arroba visible) y activa una validación básica del navegador antes incluso de que actúe el JavaScript propio.

El atributo novalidate del formulario. Desactiva a propósito la validación nativa del navegador (esos globos genéricos como "Rellena este campo") para sustituirla por la validación en JavaScript de main.js, que muestra el mensaje de error en el idioma y estilo del propio sitio, campo por campo, en vez del mensaje genérico del navegador.

header.php

<a class="skip-link" href="#main-content">Saltar al contenido</a>
...
<main id="main-content" class="site-main">

Skip link. Es el primer elemento del <body>, pero está oculto visualmente (desplazado fuera de la pantalla en CSS) hasta que recibe el foco del teclado —normalmente pulsando Tab nada más cargar la página—. En ese momento se hace visible y ofrece un atajo directo al contenido principal, para que alguien navegando solo con teclado no tenga que pasar tabulando por todo el menú en cada página que visita.

CSS

Todo el color y la tipografía del sitio salen de once variables — cambiar el sistema de diseño es cambiar once líneas, no perseguir valores sueltos por cien archivos.

style.css

:root {
  --color-bg: #FAF6F0;
  --color-accent: #7A2E33;
  --font-heading: "Fraunces", Georgia, serif;
  --font-body: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
  --space-3: 16px;
  --space-4: 24px;
}

h2 { font-size: 1.25rem; }   /* 20px mobile */

@media (min-width: 768px) {
  h2 { font-size: 1.375rem; } /* 22px tablet */
}

@media (min-width: 1440px) {
  h2 { font-size: 1.5rem; }   /* 24px desktop */
}

Variables CSS: una agenda de contactos, no notas sueltas. Si guardaras el número de un amigo escrito a mano en diez sitios distintos, cambiarlo significaría buscar y corregir los diez. Guardarlo una vez en la agenda —y que los diez sitios "llamen" a la agenda cuando lo necesitan— significa cambiarlo una sola vez. --color-accent o --font-heading son esa agenda: si mañana cambia el burdeos, se edita una línea en :root, no se persigue el valor por cada archivo. Es la implementación técnica directa de las decisiones de la Fase 3 —el h2 a 24px en escritorio que justificamos allí por jerarquía visual es, aquí, literalmente ese número en el código—.

Media queries: trajes que se adaptan según la talla. En vez de un único traje que le queda bien a una sola persona, una regla CSS dentro de una media query dice "esto solo se aplica a partir de esta talla (ancho de pantalla) en adelante". Aquí van creciendo con min-width (768px, luego 1440px) en vez de encogerse con max-width — es la implementación técnica del mobile-first que ya justificamos en la Fase 2: el estilo base sin ninguna media query es el de mobile, y cada media query añade complejidad hacia arriba, nunca la resta.

Una lección concreta documentada durante el desarrollo: .site-main no lleva overflow-x: hidden, aunque sería la forma más rápida de evitar el scroll horizontal del hero a sangre completa. La razón es que overflow distinto de visible en un contenedor rompe position: sticky en sus descendientes —en este caso, la barra lateral de "Conceptos clave" de cada actividad dejaba de quedarse fija—. El recorte de desbordamiento se aplica al componente concreto que lo necesita (el propio hero), no al contenedor general.

components.css

.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

Flexbox: colocar sillas en fila para un evento. Si tienes que sentar a un grupo de gente en una fila y repartir el espacio libre entre ellos sin medir con una regla, eso es exactamente lo que hace display: flex: coloca los elementos en fila (o columna) y reparte el espacio sobrante automáticamente. Es el sistema de alineación más usado en todo el proyecto —header, footer, tarjetas, formularios, pills de filtro—; aquí concretamente separa el logo a un extremo y el menú al otro con justify-content: space-between, como poner a alguien en cada punta de la mesa y dejar hueco en medio.

components.css

.actividades-grid {
  display: grid;
  grid-template-columns: 1fr;
}

@media (min-width: 768px) {
  .actividades-grid { grid-template-columns: repeat(2, 1fr); }
}

@media (min-width: 1440px) {
  .actividades-grid { grid-template-columns: repeat(3, 1fr); }
}

CSS Grid: un tablero de ajedrez, no una sola fila. Flexbox reparte en una sola dirección; cuando hace falta encajar piezas por filas Y columnas a la vez —como las casillas de un tablero—, hace falta display: grid. Aquí decide cuántas columnas tiene la cuadrícula de actividades según el espacio disponible: 1 en mobile, 2 en tablet, 3 en escritorio. repeat(3, 1fr) crea tres columnas de igual ancho sin escribir 1fr 1fr 1fr a mano —como pedir "tres raciones iguales" en vez de cortar el pastel a ojo tres veces—.

Pseudo-clases de estado: un semáforo que cambia solo cuando pasa algo. :hover, :focus, :disabled aplican un estilo distinto según lo que esté pasando en ese momento —igual que un semáforo cambia de color solo cuando se dan ciertas condiciones—, sin que nadie tenga que estar mirando y decidiéndolo con JavaScript. :hover ilumina las tarjetas al pasar el ratón, :disabled atenúa el botón "Cargar más" mientras espera respuesta del servidor. El proyecto usa :focus-visible en vez de :focus a secas —solo muestra el contorno de foco cuando se navega con teclado, no al hacer clic con el ratón, que es cuando ese contorno resulta visualmente molesto y no aporta nada—.

Pseudo-elementos: una pegatina añadida sin escribir nada nuevo en la libreta. El subrayado que marca el ítem de menú activo no es un <span> extra en el HTML: es contenido generado por el propio CSS con ::after, como pegar una pegatina decorativa sobre una página sin tener que reescribirla. Permite añadir un elemento puramente decorativo sin ensuciar el marcado con un nodo que no aporta contenido real.

components.css

.nav-toggle[aria-expanded="true"] .nav-toggle__bar:nth-child(1) {
  transform: translateY(7px) rotate(45deg);
}

Transform mueve, transition lo hace a cámara lenta. transform es literalmente mover, rotar o estirar un elemento —como correr un mueble de sitio—; transition es grabar ese movimiento a cámara lenta en vez de que ocurra de un salto. El icono de hamburguesa no cambia de golpe a una "X": sus tres barras rotan y se desplazan con transform, y transition hace que ese cambio se vea suave. El mismo mecanismo eleva ligeramente las tarjetas al pasar el ratón.

position: sticky: un marcapáginas que se queda fijo mientras sigues leyendo. Aparece dos veces en el proyecto: en el header (siempre visible arriba al hacer scroll) y en la ficha de "Conceptos clave" de cada actividad. Es un punto intermedio entre moverse con el resto de la página y quedarse fijo del todo: el elemento se comporta con normalidad hasta que el scroll llega a un punto concreto, y a partir de ahí se "pega" ahí, como un marcapáginas que se queda a la vista mientras sigues avanzando en el libro, hasta que cierras ese capítulo (el contenedor padre deja de estar visible).

components.css

@keyframes spin {
  to { transform: rotate(360deg); }
}

.preloader__spinner { animation: spin 600ms linear infinite; }

@media (prefers-reduced-motion: reduce) {
  .preloader__spinner { animation: none; }
}

@keyframes: un flipbook, no un vídeo grabado. Un flipbook (esos cuadernillos que al pasar rápido las páginas parecen un dibujo animado) solo necesita que dibujes el principio y el final de cada movimiento; el ojo rellena lo de en medio. @keyframes funciona igual: aquí solo se define el punto de partida y el de llegada de un giro completo —el spinner del preloader—, y el navegador dibuja los pasos intermedios solo. Igual que las animaciones de entrada al hacer scroll, se desactiva explícitamente si el sistema operativo tiene activada la reducción de movimiento, esta vez a nivel de CSS puro, sin necesidad de JavaScript para este caso concreto.

Selectores de atributo: buscar por una etiqueta que la ropa ya llevaba puesta. Si una prenda ya lleva cosida una etiqueta con la talla, no hace falta coserle una segunda etiqueta solo para poder buscarla por talla — basta con leer la que ya tiene. [data-animar] y [aria-expanded="true"] hacen eso: seleccionan elementos por un atributo que ya existe ahí por otro motivo (marcar qué animar, o comunicar accesibilidad), en vez de añadir una clase CSS nueva solo para poder encontrarlos.

Nomenclatura BEM: apellidos de familia para las clases. Con solo oír el apellido de alguien ya sabes de qué familia es, sin tener que preguntar. card-actividad__title o filtro-pill.is-active funcionan igual: el nombre de la clase ya dice a qué componente pertenece con solo leerlo, sin tener que ir a comprobar el HTML para saberlo. No es una propiedad de CSS, es solo una forma de nombrar las cosas, y evita que los estilos de un componente choquen por accidente con los de otro.

JavaScript

El filtro por tecnología intercepta clics sobre enlaces reales — nunca deja de ser una URL rastreable, solo evita la recarga de página.

assets/js/main.js

filtros.addEventListener( 'click', function ( evento ) {
    var enlace = evento.target.closest( '.filtro-pill' );
    if ( ! enlace ) {
        return;
    }

    evento.preventDefault();

    filtros.querySelectorAll( '.filtro-pill' ).forEach( function ( pill ) {
        pill.classList.remove( 'is-active' );
    } );
    enlace.classList.add( 'is-active' );

    tecnologiaActiva = enlace.getAttribute( 'data-tecnologia' );
    paginaActual = 1;
    pedirActividades( paginaActual, tecnologiaActiva, ordenActivo, true );

    if ( window.history && window.history.pushState ) {
        window.history.pushState( {}, '', enlace.getAttribute( 'href' ) );
    }
} );

Las pills de tecnología son enlaces <a href> reales hacia /tecnologia/{slug}/ —decisión ya justificada en la Fase 2—, así que funcionan aunque JavaScript falle. Cuando sí carga, este listener intercepta el clic con evento.preventDefault() antes de que el navegador navegue, y en su lugar pide los mismos datos por AJAX y actualiza la URL manualmente con history.pushState() —así la barra de direcciones queda correcta (compartible, con botón atrás funcional) sin la recarga completa de página que tendría un enlace normal.

evento.target.closest('.filtro-pill') es delegación de eventos: como un portero que firma por todos los paquetes en la entrada del edificio, en vez de que cada vecino tenga que bajar a firmar el suyo. El listener está en el contenedor filtros, no en cada pill por separado, así que también funciona con pills añadidas más tarde al DOM sin tener que volver a engancharlas una a una.

La función pedirActividades() encapsula toda la llamada AJAX: construye un objeto FormData —una estructura de pares clave-valor, como un array asociativo, pensada para enviarse directamente en una petición—, hace fetch() contra el endpoint de WordPress, y encadena .then() dos veces: primero para convertir la respuesta a JSON, después para actualizar el DOM con el resultado. Esa cadena de promesas es lo que permite que la interfaz no se congele mientras espera la respuesta del servidor.

assets/js/main.js

( function () {
    'use strict';
    // ... todo el archivo vive aquí dentro ...
} )();

IIFE: una habitación con la puerta cerrada. Todo main.js está envuelto en una función que se define y se llama a sí misma en el mismo momento —Immediately Invoked Function Expression, función que se ejecuta inmediatamente—. Es como cerrar la puerta de una habitación antes de desordenarla: lo que pasa dentro (las variables preloader, grid, paginaActual...) no sale de ahí ni se mezcla con el resto de la casa (otros scripts cargados en la misma página). 'use strict' además activa un modo más estricto del lenguaje que convierte en error explícito cosas que, de otra forma, JavaScript dejaría pasar en silencio —por ejemplo, usar una variable que no se ha declarado—.

addEventListener: un vigilante que solo actúa cuando pasa algo concreto. No hace nada hasta que ocurre justo lo que está esperando: click (pills de filtro, botón de cerrar modal), scroll (barra de progreso, botón "volver arriba"), keydown (tecla Escape para cerrar el modal), load (ocultar el preloader cuando la página termina de cargar del todo), blur (validar un campo del formulario en cuanto se abandona, sin esperar al envío) y submit (validación final antes de enviar el formulario).

Selección del DOM: encontrar a alguien por su carnet o por el color de su camiseta. getElementById busca por algo único, como un carnet de identidad —se usa para un elemento único y conocido de antemano, como #preloader—. querySelectorAll busca por algo que puede compartir más de uno, como el color de una camiseta —todas las .filtro-pill a la vez, para quitarles la clase activa antes de marcar una nueva—.

classList: un interruptor de la luz. Es la forma moderna de añadir o quitar una clase CSS desde JavaScript sin tocar directamente el atributo class como si fuera texto suelto. toggle es el más compacto: como un interruptor, si la luz está encendida la apaga, y si está apagada la enciende, con el mismo gesto —se usa en el menú móvil para abrir/cerrar con la misma línea de código—.

forEach: repartir un folleto a cada persona de la cola. Sin tener que contar antes cuánta gente hay, simplemente reparte lo mismo a cada elemento de la lista —aquí, a todas las .filtro-pill devueltas por querySelectorAll— sin necesidad de escribir un bucle for tradicional con contador manual.

assets/js/main.js

var prefiereMenosMovimiento = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;

if ( ! prefiereMenosMovimiento && 'IntersectionObserver' in window ) {
    observador = new IntersectionObserver( function ( entradas ) {
        entradas.forEach( function ( entrada ) {
            if ( entrada.isIntersecting ) {
                entrada.target.classList.add( 'is-visible' );
                observador.unobserve( entrada.target );
            }
        } );
    }, { threshold: 0.1 } );
}

IntersectionObserver: un sensor de movimiento en la puerta de una tienda. En vez de tener a alguien vigilando la puerta sin parar (comprobar la posición del scroll constantemente, que gasta muchos recursos), un sensor avisa solo cuando de verdad entra alguien. En cuanto una tarjeta se vuelve visible (entrada.isIntersecting), se le añade la clase que dispara su animación de entrada, y unobserve() apaga el sensor para esa tarjeta en concreto: ya ha aparecido una vez, no hace falta seguir vigilándola.

matchMedia: preguntar por el tiempo antes de coger el paraguas. Es la versión en JavaScript de una media query CSS: permite comprobar, desde el propio script, si el sistema operativo tiene activada la preferencia de "reducir movimiento" —como asomarse a preguntar si llueve antes de decidir si merece la pena salir con paraguas—. Aquí decide si compensa siquiera crear el IntersectionObserver: si la persona no quiere animaciones, no tiene sentido gastar recursos detectando cuándo dispararlas.

Expresiones regulares: una plantilla de crucigrama que solo acepta ciertas formas. El patrón /^[^\s@]+@[^\s@]+\.[^\s@]+$/ valida el formato del email en el formulario de contacto, como una plantilla que solo deja pasar palabras con una forma concreta: algo antes de una arroba, algo después, y un punto seguido de más texto, sin espacios en ningún punto. Es una comprobación básica de formato —no confirma que el email exista de verdad—, pensada para detectar errores de escritura evidentes antes de enviar el formulario.

assets/js/main.js

var modal = document.createElement( 'div' );
modal.id = 'imagen-modal';
modal.setAttribute( 'role', 'dialog' );

var modalImg = document.createElement( 'img' );
modalImg.className = 'imagen-modal__img';

modal.appendChild( modalCerrar );
modal.appendChild( modalImg );
document.body.appendChild( modal );

Creación dinámica de elementos: montar el mueble solo cuando hace falta. El modal de imagen de "Evolución y versiones" no existe en el HTML de la página desde el principio: se construye por completo desde JavaScript la primera vez que hace falta, como montar un mueble justo cuando se necesita en vez de tenerlo ya armado ocupando espacio en el salón desde el primer día. Esto evita tener marcado invisible cargado de antemano si nunca llega a usarse, y centraliza toda su lógica —creación, apertura, cierre— en un único sitio del script.

URL y searchParams: cambiar la etiqueta de un cajón sin mover el mueble entero. Al cambiar el orden de "Más reciente" a "Más antiguo", el script no recarga la página entera: construye un objeto URL a partir de la dirección actual y usa searchParams.set('orden', 'asc') o .delete('orden') para tocar solo ese parámetro concreto, sin mover nada más (ni la ruta, ni otros parámetros que pudiera haber), antes de aplicarlo con pushState.

Operador ternario y salida temprana: "si no hay nadie en la puerta, no sigas llamando". Comprobaciones como if (!enlace) return; al principio de una función cortan la ejecución de inmediato si la condición no se cumple, en vez de anidar todo el resto del código dentro de un if más grande. Frases compactas como botonCargarMas.hidden = ! resultado.data.hasMore hacen lo mismo en una sola línea: deciden un valor u otro según una condición, sin escribir un if/else completo para algo tan simple.

PHP / WordPress

Ni el filtrado ni el orden esconden nada en el cliente — cada clic dispara una consulta real contra la base de datos.

PHP no se impartió en el curso — solo se usaron algunas expresiones sueltas, copiadas de webs concretas, sin entrar en el lenguaje en profundidad. Este proyecto necesitaba bastante más que eso, así que este nivel se aprendió específicamente para el proyecto, con Claude como apoyo para entender los patrones propios de WordPress (hooks, nonces, taxonomías) más allá de lo visto en clase. Por eso este subapartado está partido en dos: primero PHP como lenguaje —conceptos que ya conoces de JavaScript y que aquí solo cambian de sintaxis—, y después la API específica de WordPress, que es donde de verdad estuvo el esfuerzo de aprendizaje.

PHP como lenguaje

Los siguientes conceptos se explican con analogías del día a día antes que con vocabulario técnico —pensado para cualquiera que esté escuchando sin haber tocado nunca código, no solo para el tribunal—.

inc/template-helpers.php

function marcborrell_social_links() {
    return array(
        'github' => array(
            'label' => 'GitHub',
            'icon'  => 'bi-github',
            'url'   => 'https://github.com/Wurrey',
        ),
        // ...
    );
}

Función que devuelve algo. Piensa en una receta: le das los ingredientes, ella hace los pasos, y te devuelve el plato ya listo. marcborrell_social_links() es esa receta: cada vez que se "pide", devuelve ya preparada la lista de redes sociales, en vez de escribirla a mano en cada página donde hace falta —el footer y la página de Contacto la piden por igual—. Si mañana cambia una URL, se edita una sola vez, no en dos sitios distintos.

Constante. Es como escribir un número en un post-it el primer día y prohibirte borrarlo después. MARCBORRELL_ACTIVIDADES_POR_PAGINA es un post-it que dice "siempre son 9 actividades por página". Tanto el filtro como el botón "Cargar más" leen ese mismo post-it, así que nunca pueden quedar desincronizados por error si algún día cambia ese número.

Array indexado y array asociativo. Un array indexado es una lista de la compra numerada —1. leche, 2. pan, 3. huevos—, donde solo importa el orden. Un array asociativo es más bien una ficha con etiquetas —Nombre: Marc, Ciudad: Barcelona—, donde cada dato lleva su propia etiqueta en vez de un número de posición. $labels, en el registro de las actividades, es de este segundo tipo.

footer.php

<?php foreach ( marcborrell_social_links() as $key => $social ) : ?>
    <li>
        <a href="<?php echo esc_url( $social['url'] ); ?>">
            <?php echo esc_html( $social['label'] ); ?>
        </a>
    </li>
<?php endforeach; ?>

foreach: repartir una carta a cada persona de la mesa. Sin tener que contar antes cuántas sillas hay, simplemente dice "para cada elemento de esta lista, haz esto mismo". Aquí reparte una red social distinta a cada icono del footer, tantas veces como redes haya guardadas —ni una más, ni una menos—.

inc/metabox-evolucion.php

for ( $i = 0; $i < $total; $i++ ) {
    $titulo = $titulos[ $i ] ?? '';
    $texto  = $textos[ $i ] ?? '';
    $imagen = $imagenes[ $i ] ?? '';

    if ( ! empty( $titulo ) || ! empty( $imagen ) ) {
        $pasos[] = array(
            'titulo'    => $titulo,
            'texto'     => $texto,
            'imagen_id' => $imagen,
        );
    }
}

for: repartir tres barajas a la vez. El reparto simple de antes ("una carta a cada uno") no sirve aquí porque hace falta leer tres listas al mismo tiempo, en la misma posición —el título, el texto y la imagen del mismo paso de "Evolución y versiones" llegan como tres listas separadas—. Un bucle for con contador ($i) es como repartir tres barajas en paralelo sabiendo en todo momento "voy por la carta número 3 de las tres a la vez", algo que el reparto sencillo de foreach no permite.

El operador ??: "si esto está vacío, usa este otro valor, y no protestes". Si en un paso no se rellenó el título, en vez de que la web dé un error, $titulos[$i] ?? '' usa una cadena vacía y sigue funcionando con normalidad. El mismo operador aparece en single-actividad.php con $paso['titulo'] ?? ''.

inc/contact-form.php

$asunto = sprintf( '[%s] Nuevo mensaje de contacto de %s', get_bloginfo( 'name' ), $nombre );
$cuerpo = "Nombre: {$nombre}\nEmail: {$email}\n\nMensaje:\n{$mensaje}";

Plantilla con huecos rellenables. Es como una carta con espacios en blanco: "Estimado ___, gracias por tu mensaje sobre ___". sprintf rellena esos huecos (cada %s) con datos reales —el nombre, el asunto— en vez de tener que cortar y pegar trozos de texto a mano. La línea de $cuerpo hace lo mismo de otra forma: dentro de una cadena entre comillas dobles, {$nombre} se sustituye solo por el valor real de esa variable.

inc/metabox-ficha.php

register_post_meta( 'actividad', 'actividad_ficha_resumen', array(
    'show_in_rest'  => true,
    'auth_callback' => function() {
        return current_user_can( 'edit_posts' );
    },
) );

Función anónima: contratar a alguien de usar y tirar. En vez de contratar a alguien fijo, con nombre y ficha propia, para una tarea puntual, aquí se define una función "sin nombre" justo donde hace falta, para un único uso —como valor de auth_callback—. WordPress la ejecuta cada vez que alguien intenta leer o escribir este campo desde fuera, para comprobar en ese momento si tiene permiso. No necesita nombre propio porque nunca se la vuelve a llamar por separado desde ningún otro sitio.

inc/seo.php

if ( is_front_page() ) {
    $descripcion = '...';
} elseif ( is_post_type_archive( 'actividad' ) || is_tax( 'tecnologia' ) ) {
    $descripcion = '...';
} elseif ( is_singular( 'actividad' ) ) {
    $descripcion = wp_trim_words( get_the_excerpt(), 30 );
} elseif ( is_page( 'sobre-mi' ) ) {
    $descripcion = '...';
}

if / elseif: el árbol de decisión de elegir qué ponerte según el tiempo. Si llueve, paraguas; si no llueve pero hace frío, abrigo; si no se cumple ninguna de las anteriores, camiseta. El código prueba cada condición en orden hasta que una encaja, y decide así qué descripción SEO mostrar según qué tipo de página se esté viendo en ese momento.

API de WordPress

Aquí el nivel sube un peldaño: esto no es PHP genérico, es el conjunto de piezas propias que WordPress pone a disposición de cualquier tema. Se explica con el mismo criterio de analogías que el bloque anterior.

header.php

<?php get_header(); ?>
...
<?php the_title(); ?>
<?php the_content(); ?>
<?php get_footer(); ?>

Template tags: piezas prefabricadas, como muebles de kit. get_header(), the_title(), the_content() son funciones que WordPress ya trae hechas de fábrica para tareas que se repiten en cualquier página —cargar la cabecera, imprimir el título, imprimir el contenido—. En vez de que cada plantilla reinvente cómo sacar esos datos de la base de datos, usa la pieza ya montada.

index.php

<?php if ( have_posts() ) : ?>
    <?php while ( have_posts() ) : the_post(); ?>
        <?php the_title(); ?>
    <?php endwhile; ?>
<?php endif; ?>

El "Loop" (have_posts() / the_post()): una cinta transportadora. have_posts() pregunta "¿queda algo en la cinta?"; the_post() coge la siguiente pieza de esa cinta y la coloca en posición de trabajo para que el resto del código la procese —de ahí que después se pueda usar the_title() sin decirle de qué actividad, porque ya se sabe cuál es "la pieza actual" sobre la mesa—. Es el patrón que WordPress usa en todas partes: en el listado de actividades, en cada ficha individual, en la portada.

inc/post-types.php

register_post_type( 'actividad', array(
    'public'      => true,
    'has_archive' => 'actividades',
    'rewrite'     => array( 'slug' => 'actividades' ),
) );

Registrar un CPT: abrir una carpeta nueva en el archivador. De fábrica, WordPress solo entiende dos tipos de contenido: "Entradas" y "Páginas". register_post_type('actividad', ...) le enseña a entender un tercer tipo, con su propio nombre y sus propias reglas —entre ellas, has_archive, que es lo que le dice "genera tú solo una carpeta pública que las liste todas, en /actividades/", sin que haga falta crear esa página a mano—.

inc/taxonomies.php

register_taxonomy( 'tecnologia', array( 'actividad' ), array(
    'hierarchical' => false,
) );

Taxonomía no jerárquica: etiquetas de Gmail, no carpetas. Una carpeta de correo solo puede estar en un sitio a la vez —eso es "jerárquico", como las categorías de WordPress—. Una etiqueta de Gmail se puede poner varias veces sobre el mismo correo a la vez —eso es "no jerárquico", 'hierarchical' => false—. Se eligió así a propósito porque una actividad puede ser HTML, CSS y JavaScript a la vez, igual que un correo puede llevar varias etiquetas sin tener que elegir solo una.

single-actividad.php

$resumen = get_post_meta( get_the_ID(), 'actividad_ficha_resumen', true );

Campos personalizados (get_post_meta): casillas extra pegadas a la ficha. Una actividad de WordPress trae de serie título, contenido e imagen destacada — pero "resumen del aprendizaje" o "cita destacada" no existen de fábrica. get_post_meta() lee esas casillas extra, añadidas a mano para este proyecto, guardadas por separado del contenido principal pero enlazadas a la misma actividad por su ID.

Funciones de sanitización y escapado: el guardia de seguridad, a la entrada y a la salida. sanitize_text_field() revisa un dato justo cuando entra —por ejemplo, lo que alguien escribió en el formulario de contacto—, antes de guardarlo o usarlo. esc_html() y esc_url() revisan un dato justo antes de imprimirlo en pantalla, ya de salida. Son dos controles en dos puntos distintos, no el mismo control repetido dos veces: cada uno evita un problema de seguridad diferente si alguien intentara colar código malicioso disfrazado de texto normal.

Superglobales ($_POST, $_GET) y isset(): un buzón compartido de toda la casa. $_POST y $_GET son "buzones" donde PHP deja automáticamente los datos que llegan de un formulario o de la URL, accesibles desde cualquier parte del código. isset($_POST['tecnologia']) comprueba que el buzón no está vacío antes de intentar coger algo de él, para no producir un error si esa vez no llegó ese dato en concreto.

Conditional tags (is_front_page(), is_tax()...): preguntar "¿dónde estoy?" antes de hablar. Como un GPS que primero confirma en qué calle está antes de dar instrucciones. Estas funciones le preguntan a WordPress qué tipo de página se está mostrando en ese momento exacto, y el código decide su comportamiento —qué meta-descripción SEO mostrar, por ejemplo— según la respuesta.

inc/metabox-ficha.php

register_post_meta( 'actividad', 'actividad_ficha_resumen', array(
    'show_in_rest' => true,
) );

show_in_rest: abrir una ventanilla online además de la de siempre. Por defecto, un campo personalizado solo se puede leer o rellenar desde dentro del panel de administración de WordPress, como un trámite que solo se hace en persona en el ayuntamiento. show_in_rest => true abre una "ventanilla online" adicional —la API REST—, para que herramientas externas puedan leer o escribir ese campo sin pasar por el editor visual. Se usó puntualmente para la carga inicial de las 29 fichas.

inc/customizer.php

$wp_customize->add_setting( 'hero_image_url', array( ... ) );
$wp_customize->add_control( new WP_Customize_Image_Control( $wp_customize, 'hero_image_url', array(
    'label'   => 'Imagen del hero (portada)',
    'section' => 'marcborrell_portada',
) ) );

Customizer API: un panel de mandos en vez de abrir la caja y tocar cables. Sin esto, cambiar la imagen del hero de Inicio significaría editar directamente un archivo de código. add_setting + add_control crean, en su lugar, un interruptor visual dentro de Personalizar → Portada — Inicio, donde se sube la imagen desde una pantalla normal, sin tocar ni ver una sola línea de PHP.

Y para cerrar el círculo: check_ajax_referer() verifica el nonce antes de ejecutar nada más —un nonce es como una pulsera de un festival de un solo día, con la fecha impresa y comprobada en la puerta: no sirve para otro día ni se puede copiar fácilmente para dársela a otra persona—. Sin él, cualquiera podría disparar peticiones automatizadas contra este endpoint. Las dos líneas add_action('wp_ajax_...') y add_action('wp_ajax_nopriv_...') son necesarias porque WordPress trata a un visitante identificado y a uno anónimo como dos rutas distintas —como este filtro debe funcionar para cualquiera que visite el portfolio, no solo para el administrador, hace falta registrar la función en ambas—.

$args y tax_query son arrays: el primero reúne los parámetros de la consulta (tipo de contenido, cuántos resultados, qué página), y tax_query es un array anidado que solo se añade con una condicional cuando de verdad hay un filtro activo. new WP_Query($args) es como pedirle a un bibliotecario real que vaya a buscar entre las estanterías —"tráeme todos los libros de la sección X"—, no consultar una lista de libros ya elegidos de antemano: es una llamada real a la base de datos, WordPress la traduce en una consulta SQL contra las tablas de posts y taxonomías.

Aquí $pasos es un array de arrays asociativos —cada paso de "Evolución y versiones" guarda imagen_id, titulo y texto—, recorrido con foreach. La alternancia de imagen a la derecha o a la izquierda no es aleatoria: usa el operador módulo ($indice % 2), el mismo truco con el que se decide si un número es par sin ninguna librería, aplicado aquí para alternar el layout automáticamente sin importar cuántos pasos tenga cada actividad.

Por último, la jerarquía de plantillas ya mencionada en la Fase 1 —como un sistema de buzones con etiquetas: si existe un buzón con el nombre exacto que hace falta, el cartero lo usa; si no, cae en el buzón genérico— es la razón por la que archive-actividad.php y taxonomy-tecnologia.php existen: WordPress genera las URLs /actividades/ y /tecnologia/{slug}/ automáticamente en cuanto detecta esos archivos con esos nombres exactos, sin necesidad de crear ninguna Página a mano ni asignarle una plantilla manualmente —al contrario que la ruta descartada, page-proyectos.php, que dependía de una Página física y quedó desincronizada del resto del sistema hasta que se eliminó.

Fase 05

Retos y resolución de problemas

Siete obstáculos reales. La mayoría siguen el mismo esquema de qué pasó, cómo se detectó, y cómo se resolvió; los dos últimos ocurrieron mientras se construía esta misma web de proceso — no son anécdotas del pasado, son depuración en tiempo real.

404 en el archivo de actividades

La URL /actividades/ daba error 404 hasta cambiar una sola palabra en el registro del CPT.

Qué pasó: el listado general de actividades, pensado para generarse solo en /actividades/, devolvía un error 404 de página no encontrada.

Cómo se detectó: al navegar directamente a esa URL para comprobar el archivo recién creado.

Cómo se resolvió: el registro del Custom Post Type tenía 'has_archive' => false —el valor que WordPress trae por defecto—, así que nunca llegó a crear esa página automática. Cambiarlo a 'has_archive' => 'actividades' fue la línea exacta que faltaba; sin ese cambio, ningún archivo de plantilla, por bien escrito que estuviera, iba a tener efecto, porque WordPress ni siquiera generaba la URL que debía usarlo.

La ficha que dejaba de seguir el scroll

El mismo componente sticky se rompió dos veces, por dos motivos completamente distintos.

Primer episodio — qué pasó: la barra lateral de "Conceptos clave" debía quedarse fija en pantalla mientras se leía la descripción larga de una actividad, pero se comportaba como un elemento normal: subía y desaparecía con el resto del contenido.

Cómo se detectó: comparando visualmente el comportamiento esperado (definido en el prototipo de Figma) contra lo que realmente pasaba al hacer scroll en el navegador.

Cómo se resolvió: .site-main tenía overflow-x: hidden para evitar el scroll horizontal del hero a sangre completa —una solución rápida y aparentemente inocente—, pero cualquier valor de overflow distinto de visible en un contenedor rompe position: sticky en sus descendientes. Se quitó ese overflow-x del contenedor general y se aplicó, en su lugar, solo al propio componente del hero que lo necesitaba.

Segundo episodio — qué pasó: arreglado lo anterior, la ficha volvió a dejar de pegarse al reorganizar el layout de la página.

Cómo se resolvió: position: sticky necesita que el elemento sea descendiente directo del contenedor que actúa como su límite de scroll; un <div> extra envolviendo solo la ficha, fuera del contenedor de la cuadrícula principal, rompía esa relación. La solución fue mover la ficha dentro del mismo contenedor grid que el resto del contenido de la actividad, no en un envoltorio aparte.

La flecha que se convertía en emoji

Un mismo carácter de flecha se veía como texto en un sitio y como emoji en color en otro.

Qué pasó: el carácter usado en enlaces como "Proceso ↗" se renderizaba a veces como texto plano (coherente con el resto de la tipografía) y a veces como un emoji en color, dependiendo del navegador y sistema operativo.

Cómo se detectó: revisando la web en distintos dispositivos durante las pruebas de usabilidad propias.

Cómo se resolvió: algunos sistemas interpretan ciertos caracteres Unicode como emoji por defecto salvo que se indique lo contrario. Añadir el variation selector &#xFE0E; justo después del carácter (&#x2197;&#xFE0E;) le dice explícitamente al navegador "muéstralo como texto, no como emoji en color" — una sola entidad HTML invisible que resuelve la inconsistencia en todos los casos.

Migración de hosting sin experiencia previa en servidores

Migrar un WordPress vivo a otro proveedor, sin haber administrado nunca un servidor.

Qué había que lograr: mover el sitio completo —base de datos, ficheros del tema, dominio— desde el hosting inicial (cdmon, plan de prueba) a un hosting de pago en Raiola Networks, sin perder contenido ni romper ninguna URL ya indexada.

Cómo se abordó: migración manual, no automatizada: exportación e importación de la base de datos vía phpMyAdmin, transferencia de ficheros por FileZilla y el gestor de archivos del hosting, y sustitución de rutas antiguas por las nuevas con el plugin Better Search Replace — 117 reemplazos de URL en total. Claude guio cada paso del proceso (qué exportar, en qué orden, qué comprobar después de cada cambio); la ejecución sobre el panel real, el hosting y el servidor fue propia.

Resultado: sitio operativo en el dominio definitivo wurrey.com, con SSL, permalinks y desindexación de buscadores ya configurados desde el nuevo hosting.

El archivo huérfano de una versión anterior

Un archivo de una versión descartada seguía en el servidor, sin usarse pero sin desaparecer, hasta que esta misma web de proceso lo sacó a la luz.

Qué pasó: mientras se documentaba la Fase 4 de esta web, aparecieron dos implementaciones distintas del listado filtrable de actividades: la vigente (archive-actividad.php + taxonomy-tecnologia.php) y una plantilla de página independiente (page-proyectos.php) con pills sin URL real y sin control de orden.

Cómo se detectó: al preparar los fragmentos de código reales para justificar la arquitectura, la existencia de dos rutas para lo mismo no cuadraba con el resto del sistema —ni el filtro pre_get_posts ni el endpoint AJAX hacían referencia a esa segunda plantilla en ningún comentario del código—.

Cómo se resolvió: revisando las pistas del propio código (comentarios que solo nombraban las dos plantillas vigentes, pills sin href real —justo lo que se había descartado en la Fase 2 de planificación—, ausencia del control de orden), se confirmó que era un remanente de una iteración anterior, sobrescrito parcialmente por FTP sin llegar a eliminarse del todo. Se borró el archivo y se comprobó que no quedara ninguna Página en WordPress con esa plantilla asignada.

El backdrop que bloqueaba su propio botón

El fondo oscurecido del menú móvil de esta web tapaba, sin querer, el botón que debía cerrarlo.

Qué pasó: al construir el índice de fases sticky para mobile de esta misma web, el fondo oscurecido (backdrop) que aparece detrás del menú desplegable impedía volver a pulsar el botón "Índice de fases" para cerrarlo.

Cómo se detectó: probando el componente paso a paso con capturas automatizadas en distintos puntos de scroll, en vez de darlo por bueno a la primera.

Cómo se resolvió: como dos hojas de papel apiladas sobre la mesa —la de arriba tapa a la de abajo aunque ambas estén ahí—, el backdrop tenía un z-index más alto que el propio botón —que no tenía ninguno explícito, así que quedaba debajo en desventaja—. Dar al botón y a la lista un z-index superior al del backdrop, dentro del mismo contexto de apilamiento, devolvió el botón a la superficie y lo dejó siempre pulsable.

Fase 06

Resultado y aprendizajes

El cierre: qué se consiguió, qué se aprendió por el camino, y qué queda pendiente para después de la defensa — dicho con la misma honestidad que el resto de esta web.

Resultados obtenidos

Un portfolio con 29 actividades reales, filtrable y editable, más esta misma web y su Exposición como soporte de defensa.

El resultado son tres sitios independientes: el portfolio en wurrey.com/, con 29 actividades del curso organizadas como Custom Post Type, filtrables por tecnología, cada una con su ficha individual y su evolución documentada; esta web de proceso (Memoria), pensada como referencia detallada para la defensa oral del 30 de julio; y la Exposición, un recorrido de diapositivas con las frases clave de cada fase, pensado para hablarse en voz alta durante la propia defensa, con enlaces de vuelta a esta Memoria para cualquier pregunta puntual.

Similitud respecto a la propuesta gráfica: el resultado final se corresponde de forma cercana con la propuesta de alta fidelidad hecha en Figma —mismos tokens de color y tipografía, misma estructura de páginas en los tres breakpoints—, con las pocas desviaciones puntuales ya documentadas y justificadas en la Fase 3 (el ajuste de tamaño del h2) y en la Fase 5 (los retos técnicos resueltos sobre la marcha).

Navegación y usabilidad de los contenidos: el árbol de navegación de la Fase 2 (filtros por tecnología en vez de menú tradicional) se validó con pruebas de usabilidad propias, continuas durante todo el desarrollo — no un trámite final, sino revisión constante mientras cada pieza se construía, incluida esta misma web de proceso.

Legibilidad de los contenidos: resuelta con el pairing tipográfico de la Fase 3 (Fraunces para personalidad, Inter optimizada para pantalla) y con la separación consistente entre imagen y título en cada ficha de actividad, para que la lectura no dependa del contraste de cada foto concreta.

Facilidad de gestión de los contenidos por el cliente: al ser el propio Marc el "cliente" de este proyecto —tal como se explicó en la Fase 1—, la facilidad de gestión se mide de cara a él mismo: los metaboxes "Evolución y versiones" y "Ficha — conceptos clave", además de las opciones del Customizer (imagen del hero, email de contacto), permiten editar el contenido real desde el panel de WordPress sin tocar código, incluso ahora que el script puntual usado para la carga inicial de las 29 fichas ya se ha revocado.

Aprendizajes

El mayor aprendizaje fue PHP y la API de WordPress, nunca dados en clase — y verbalizar por qué cada decisión ya tomada era la correcta.

El aprendizaje técnico más grande del proyecto fue PHP y la API de WordPress —no impartidos en el curso—, aprendidos de forma autodirigida con apoyo de Claude para entender patrones (hooks, nonces, taxonomías, jerarquía de plantillas) más allá de lo visto en clase. Pero el aprendizaje menos esperado fue otro: construir esta misma web de proceso obligó a revisar y verbalizar decisiones que, en su momento, se tomaron de forma más intuitiva que consciente —el ejercicio de ponerles nombre técnico y justificar la alternativa descartada fue, en sí mismo, un ejercicio de aprendizaje tan grande como escribir el código original.

Las pruebas de usabilidad y navegación fueron continuas y autocríticas a lo largo de todo el desarrollo, hechas en solitario a propósito para no filtrar información sobre el proyecto antes de la defensa —no un trámite puntual al final, sino una revisión constante mientras cada pieza se iba construyendo, incluida esta propia web de proceso, donde varios fallos reales (el bug de z-index del menú móvil, el archivo huérfano de una versión anterior) se detectaron y corrigieron sobre la marcha, tal como se documentó en la Fase 5.

Próximos pasos — aspectos a resolver en una fase siguiente

El proyecto no termina en la defensa: quedan repasos finales y una revisión estructural pendientes.

La migración del sitio a un hosting definitivo —inicialmente prevista como trabajo posterior a la defensa— se adelantó y ya está completada: el proyecto vive en Raiola Networks bajo el dominio definitivo wurrey.com, documentada en la Fase 5. Lo que sí queda pendiente, deliberadamente para después del 30 de julio, es una revisión estructural más amplia: la Exposición y esta Memoria han crecido bastante en fases y subapartados, y merece la pena valorar si esa estructura se puede simplificar sin perder contenido.

Otras líneas de mejora realistas para una siguiente fase: una revisión visual completa en mobile de todo el contenido de esta Memoria y de la Exposición; comprobar que los titulares de cada diapositiva de la Exposición se correspondan bien con su apartado equivalente aquí; formalizar un ejercicio de benchmarking y de público objetivo más allá de la reformulación honesta hecha en la Fase 1; y seguir revisando si queda algún rincón de esta web con lenguaje demasiado técnico para el público no especializado.