<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://carloslb.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://carloslb.com/" rel="alternate" type="text/html" /><updated>2026-08-03T05:35:21+00:00</updated><id>https://carloslb.com/feed.xml</id><title type="html">Carlos León Bolaños</title><subtitle>Perfil profesional - IT, Administración y Formación</subtitle><entry xml:lang="es"><title type="html">Cómo se hizo esta web (3): lo que no da error</title><link href="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-3-lo-que-no-da-error/" rel="alternate" type="text/html" title="Cómo se hizo esta web (3): lo que no da error" /><published>2026-08-02T13:00:00+00:00</published><updated>2026-08-02T13:00:00+00:00</updated><id>https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-3-lo-que-no-da-error</id><content type="html" xml:base="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-3-lo-que-no-da-error/"><![CDATA[<p>Si algo he aprendido montando este sitio es esto: <strong>el enemigo no es el error, es el silencio.</strong> Un error te dice dónde mirar. Un fallo silencioso te deja mirando una página que parece correcta durante días. Esta tercera parte va de todo lo que se rompió sin quejarse, y de cómo se caza.</p>

<p>Antes están la <a href="/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/">primera parte</a>, sobre arquitectura, y la <a href="/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo/">segunda</a>, sobre privacidad y normativa.</p>

<h2 id="cinco-fallos-que-no-dieron-ni-un-aviso">Cinco fallos que no dieron ni un aviso</h2>

<p><strong>Liquid no lanza errores: devuelve vacío.</strong> El lenguaje de plantillas de Jekyll, ante un dato mal escrito, no se queja: devuelve nada. El resultado es un enlace con destino vacío, o un rótulo en blanco. La página se genera, se publica y está rota. Descubrí que un identificador entre comillas y sin comillas se comportaban distinto —sin comillas, lo trataba como una variable inexistente— viendo desaparecer una ilustración sin explicación alguna.</p>

<p><strong>YAML distingue mayúsculas.</strong> Escribí <code class="language-plaintext highlighter-rouge">jobtitle</code> en un archivo de datos y <code class="language-plaintext highlighter-rouge">jobTitle</code> en la plantilla. El resultado fue un dato nulo en los datos estructurados de la página. JSON perfectamente válido, dato perdido, cero avisos.</p>

<p><strong>El filtro que se calculaba y no se usaba.</strong> En el listado del blog, el filtro por perfil se guardaba correctamente en una variable… y el bucle seguía recorriendo la colección completa. El blog de IT mostraba también los artículos de Administración. Lo peor: estaba <strong>camuflado</strong>, porque el perfil que no tenía artículos sí mostraba el mensaje de «aún no hay nada» y los demás «mostraban artículos». Todo parecía funcionar.</p>

<blockquote>
  <p><strong>Lección de método: comprobar <em>qué</em> sale, no solo <em>si</em> sale.</strong></p>
</blockquote>

<p><strong>El enlace de salto sin estilos.</strong> El sitio tenía desde el primer día un enlace «Saltar al contenido» para quien navega con teclado. El marcado estaba… y nunca tuvo CSS. Llevaba semanas apareciendo como un texto suelto en la esquina de todas las páginas y yo lo leía sin verlo.</p>

<p><strong>El foco que no se movía.</strong> Y cuando por fin le puse estilos, seguía sin funcionar bien: al pulsarlo, la página bajaba hasta el contenido pero el foco del teclado se quedaba arriba. El siguiente tabulador te devolvía a la cabecera. Parecía que funcionaba. La cura fue permitir que el elemento principal pudiera recibir el foco por programa.</p>

<p>Este último merece explicación, porque es el ejemplo perfecto de por qué existe:</p>

<pre><code class="language-mermaid">flowchart LR
  accTitle: Recorrido del teclado con y sin enlace de salto al contenido
  accDescr {
    Sin enlace de salto, quien navega con teclado debe pulsar el tabulador unas doce veces
    para atravesar toda la cabecera antes de llegar al contenido, y debe hacerlo en cada
    página que visita. Con el enlace de salto, la primera pulsación del tabulador ofrece
    ir directamente al contenido con una segunda pulsación.
  }
  subgraph SIN["Sin enlace de salto"]
    direction LR
    A1["Tabulador 1"] --&gt; A2["..."] --&gt; A3["Tabulador 12"] --&gt; A4["Contenido"]
  end
  subgraph CON["Con enlace de salto"]
    direction LR
    B1["Tabulador 1:&lt;br/&gt;Saltar al contenido"] --&gt; B2["Contenido"]
  end
</code></pre>

<p>La cabecera tiene unos doce enlaces. Sin enlace de salto, alguien que navega con teclado los atraviesa <strong>en cada página que visita</strong>. Es el criterio 2.4.1 de las pautas de accesibilidad, y es nivel A: el mínimo.</p>

<p>El estilo tiene su gracia técnica. Un solo anillo de foco falla siempre contra algún fondo, y aquí hay ocho paletas. La solución fue un <strong>doble anillo concéntrico</strong>: un contorno claro y una sombra oscura alrededor. El contorno se pinta por encima de la sombra, así que uno de los dos contrasta contra cualquier fondo posible. Y va por encima de la cabecera fija, lo que cubre además el criterio 2.4.11, «Foco no oscurecido», nuevo en la versión 2.2.</p>

<h2 id="la-auditoría-veinte-informes-y-lo-que-ninguno-detecta">La auditoría: veinte informes y lo que ninguno detecta</h2>

<p>El objetivo era <strong>WCAG 2.2, nivel AA</strong>. La parte automatizada la hice con axe DevTools sobre el sitio ya publicado: veinte informes, con las ocho paletas cubiertas y la batería completa de pruebas guiadas —teclado, formularios, estructura, imágenes, tablas, diálogos, elementos interactivos—. Resultado final: cero incidencias.</p>

<p>Lo que hubo que corregir por el camino:</p>

<table>
  <thead>
    <tr>
      <th>Hallazgo</th>
      <th>Solución</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Faltaba el encabezado principal en tres perfiles y en contacto</td>
      <td>Añadido en las versiones de los dos idiomas</td>
    </tr>
    <tr>
      <td>Campos obligatorios sin marcar explícitamente para tecnologías de apoyo</td>
      <td>Declarados de forma explícita</td>
    </tr>
    <tr>
      <td>Enlace de salto sin indicador de foco (crítico)</td>
      <td>El doble anillo de arriba</td>
    </tr>
  </tbody>
</table>

<p>Pero conviene decir una cosa que se calla mucho: <strong>las herramientas automáticas detectan alrededor de un tercio de los problemas reales.</strong> El resto lo tiene que ver una persona. Eso significó:</p>

<ul>
  <li><strong>Redistribución a 320 píxeles de ancho</strong> en catorce páginas, sin barra horizontal ni pérdida de contenido.</li>
  <li><strong>Ampliar solo el texto al 200 %.</strong> Esto destapó un fallo que ninguna herramienta habría visto: al crecer el texto crecía la cabecera fija, y el desplazamiento reservado para ella se quedaba corto. Los anclajes dejaban el título medio tapado.</li>
  <li><strong>Espaciado de texto forzado</strong>, por si alguien usa su propia hoja de estilos.</li>
  <li><strong>Recorrido completo de teclado</strong>: alcance, foco visible, orden lógico, sin trampas.</li>
  <li><strong>Lector de pantalla NVDA</strong>, el más usado en Europa.</li>
</ul>

<p>Y un criterio que <strong>ninguna herramienta del mundo puede comprobar</strong>: el 3.1.2, «Idioma de las partes». Cuando en el texto en inglés dejo una expresión en español sin traducir, hay que marcarla como tal, o el lector de pantalla la pronunciará con las reglas del idioma equivocado y la línea braille aplicará contracciones que no tocan. Ninguna máquina sabe que una frase sin marcar está en otro idioma. Eso solo lo ve alguien leyendo. Es el trabajo que queda pendiente, y por eso el compromiso de accesibilidad de este sitio todavía no está publicado: prefiero escribirlo cuando pueda decir exactamente qué he medido.</p>

<h2 id="dos-detalles-de-los-que-aprendí">Dos detalles de los que aprendí</h2>

<p><strong>Movimiento reducido.</strong> El sitio respeta la preferencia del sistema de reducir animaciones. El detalle está en cómo: la duración se pone en una centésima de milisegundo, <strong>no en cero</strong>. Con cero, algunas animaciones no llegan a dispararse, y un guion que espere el aviso de «animación terminada» se queda esperando para siempre. Además es de los pocos sitios donde marcar una regla como prioritaria está justificado: implementa la voluntad del usuario, no la del desarrollador.</p>

<p><strong>El cliente nunca es frontera de seguridad.</strong> El límite de caracteres, los campos obligatorios y el contador del formulario son comodidad, no defensa. Quien puede editar el HTML puede editar el JavaScript. La defensa real está siempre en el servidor.</p>

<h2 id="publicar">Publicar</h2>

<p>El constructor nativo de GitHub Pages no me servía: está fijado a una versión de Jekyll muy anterior y no admite ni el complemento de bilingüismo ni el Sass moderno. No fue una preferencia, fue una obligación: hacía falta un flujo de trabajo propio.</p>

<pre><code class="language-mermaid">flowchart LR
  accTitle: Proceso de publicación automática del sitio
  accDescr {
    Al enviar los cambios a la rama principal del repositorio, GitHub Actions arranca un
    proceso que prepara Ruby e instala las dependencias, construye el sitio en modo
    producción, empaqueta el resultado y lo despliega en GitHub Pages, que lo sirve bajo
    el dominio propio.
  }
  A["Envío a la rama principal"] --&gt; B["GitHub Actions"]
  B --&gt; C["Ruby 3.3&lt;br/&gt;e instalación de dependencias"]
  C --&gt; D["Construcción&lt;br/&gt;en modo producción"]
  D --&gt; E["Empaquetado del sitio"]
  E --&gt; F["Despliegue en GitHub Pages"]
  F --&gt; G["carloslb.com"]
</code></pre>

<p>Tres cosas que aprendí ahí:</p>

<p><strong>Dirección y subcarpeta no son lo mismo.</strong> Una clave es protocolo más dominio; la otra es la subcarpeta dentro del dominio, y solo lleva valor si el sitio no vive en la raíz. Poner el dominio en la segunda duplicaría el dominio en cada enlace generado y rompería absolutamente todo. Antes de publicar, esa clave apuntaba a la IP privada de mi máquina virtual: de haberlo dejado así, el mapa del sitio y las direcciones canónicas se habrían publicado apuntando a una dirección de mi red local.</p>

<p><strong>Los fallos intermitentes son los peores.</strong> El complemento de bilingüismo tiene una condición de carrera documentada en los servidores de integración: un proceso escribe en un directorio de idioma que otro aún no ha creado. Falla a veces. Se desactiva la paralelización y se acabó.</p>

<p><strong>El último escollo fue tonto y muy instructivo.</strong> El envío se rechazaba por el correo del commit. Había corregido la configuración de Git, pero <strong>la configuración solo afecta a los commits futuros</strong>: el que ya estaba hecho conservaba el autor antiguo. Se resuelve reescribiendo la autoría del commit pendiente, algo inofensivo mientras no se haya subido nada.</p>

<h2 id="lo-que-me-llevo">Lo que me llevo</h2>

<ol>
  <li><strong>El fallo silencioso es el enemigo.</strong> Lo que no se queja no está bien: está callado.</li>
  <li><strong>Los números mágicos mienten.</strong> Cuando un número describe algo que puede cambiar —el ancho de un elemento, la altura de una cabecera—, tarde o temprano deja de ser cierto.</li>
  <li><strong>La cohesión previene errores que la disciplina no previene.</strong> Dos etiquetas cuyo orden importa entre sí van juntas en el mismo sitio, no «bien colocadas» por separado.</li>
  <li><strong>La carpeta de salida no siempre se vacía sola.</strong> Ante cualquier rareza: parar, vaciar, arrancar de nuevo y <em>entonces</em> diagnosticar. Perdí una tarde persiguiendo una ruta fantasma de una construcción anterior.</li>
  <li><strong>Verificar en la fuente antes de afirmar.</strong></li>
</ol>

<p>Ese último merece cerrar la serie, porque enlaza con lo que conté en la primera parte sobre trabajar con un asistente de IA. Se equivocó tres veces de la misma manera: afirmando con seguridad algo deducido de una fuente incompleta —un listado de archivos truncado, una copia desactualizada del proyecto, un nombre de fichero cortado por longitud—. En una de ellas llegó a corregirme cuando yo tenía razón.</p>

<p>Las tres las cacé porque conocía mi propio proyecto y porque algo no me cuadraba. Y esa es exactamente la misma lección que el resto del artículo: <strong>una respuesta segura y falsa es también un fallo silencioso.</strong> No da error. Suena bien. Y hay que comprobarla igual que se comprueba un bucle que «muestra artículos».</p>

<p>Sea quien sea quien te dé una respuesta, la fuente se mira.</p>

<hr />

<p>El código de todo esto está en <a href="https://github.com/carloslb-com/carloslb-com.github.io">github.com/carloslb-com/carloslb-com.github.io</a>. Si algo de aquí te sirve, te lo llevas. Y si encuentras una barrera de accesibilidad en el sitio, <a href="/contact/">escríbeme</a>: me interesa más saberlo que tener razón.</p>]]></content><author><name></name></author><category term="it" /><category term="Jekyll" /><category term="accesibilidad web" /><category term="WCAG 2.2" /><category term="NVDA" /><category term="mejora progresiva" /><category term="GitHub Actions" /><category term="GitHub Pages" /><category term="axe DevTools" /><summary type="html"><![CDATA[Casi nada de lo que se rompió en este proyecto dio un error. Un recorrido por los fallos silenciosos, la auditoría de accesibilidad y la publicación.]]></summary></entry><entry xml:lang="es"><title type="html">Cómo se hizo esta web (2): privacidad de fábrica y papeleo sin ficción</title><link href="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo/" rel="alternate" type="text/html" title="Cómo se hizo esta web (2): privacidad de fábrica y papeleo sin ficción" /><published>2026-08-02T11:00:00+00:00</published><updated>2026-08-02T11:00:00+00:00</updated><id>https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo</id><content type="html" xml:base="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo/"><![CDATA[<p>Una web personal parece inofensiva. Y sin embargo, cada tipografía servida desde un servicio ajeno, cada biblioteca traída de una red de distribución y cada botón de compartir es un tercero que recibe la dirección IP de quien te visita, sin que esa persona lo haya pedido ni lo sepa. Esta parte va de cerrar esas puertas, y del papeleo que viene después.</p>

<p>En la <a href="/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/">primera parte</a> conté la arquitectura. Aquí toca lo que no se ve.</p>

<h2 id="el-diagrama-que-me-delató">El diagrama que me delató</h2>

<p>Iba a escribir artículos con diagramas, así que monté Mermaid. Funcionaba a la primera: una línea apuntando a una red de distribución pública y listo.</p>

<p>El problema lo vi al releer la política de cookies que acababa de escribir. Aquella línea estaba en la plantilla base, es decir, <strong>en todas las páginas del sitio</strong>. Cada visitante, entrara donde entrara, le estaba entregando su dirección IP a una empresa ajena para cargar una biblioteca que solo hacía falta en los artículos con diagrama. Es decir: cero. Estaba declarando una cosa y haciendo otra.</p>

<p>Autoalojarlo costó más de lo que parecía:</p>

<ul>
  <li>El paquete moderno usa <strong>división de código</strong>: el archivo principal importa decenas de fragmentos desde una carpeta hermana. Copiar el archivo suelto rompe los imports —esa fue la razón de que un intento anterior «no encontrara archivos»—. La solución fue usar el empaquetado clásico, de archivo único.</li>
  <li><strong>GitHub no publica las versiones compiladas.</strong> En el repositorio está el código fuente; la carpeta de distribución vive en el registro de paquetes, y de ahí la replican las redes públicas. Hay que ir a buscarla al sitio correcto.</li>
  <li>Trampa de JavaScript que me costó un rato: un <code class="language-plaintext highlighter-rouge">import</code> en un guion que no está declarado como módulo lanza un error de sintaxis que <strong>mata el guion entero</strong>. No falla esa línea: no se ejecuta nada.</li>
</ul>

<p>Ahora la biblioteca vive en el propio sitio y se carga <strong>solo en las páginas que declaran tener un diagrama</strong>. Con las fórmulas matemáticas hice lo mismo, con una trampa añadida: en la versión actual, las fuentes tipográficas no vienen en el paquete y el cargador apunta por defecto a una red externa. Si no lo sobrescribes, has autoalojado el motor y sigues llamando fuera.</p>

<p>El resultado: <strong>este sitio no carga ni un solo recurso de un tercero.</strong> Tipografías, iconos, motores de diagramas y de fórmulas: todo servido desde aquí.</p>

<h2 id="sin-cookies-y-no-es-una-frase-de-marketing">Sin cookies, y no es una frase de marketing</h2>

<p>No hay cookies. Ni analítica, ni publicidad, ni sesión. Por tanto no hay banner de consentimiento, porque no habría nada que consentir.</p>

<p>Lo único que se guarda en tu navegador es tu preferencia de tema, claro u oscuro, en almacenamiento local. Según la guía sobre cookies de la Agencia Española de Protección de Datos, ese uso es almacenamiento técnico de personalización <strong>a petición del usuario</strong>, y está exento de consentimiento. La diferencia no es el mecanismo: es para qué se usa.</p>

<h2 id="el-formulario-cómo-enviar-un-mensaje-sin-servidor-propio">El formulario: cómo enviar un mensaje sin servidor propio</h2>

<p>Un sitio estático no puede procesar un formulario. Necesitas un tercero. Comparé catorce servicios con tres criterios: que los datos se traten en la Unión Europea, que haya un contrato de encargado de tratamiento de verdad, y que la capa gratuita sirva para algo.</p>

<pre><code class="language-mermaid">sequenceDiagram
  accTitle: Recorrido de un mensaje enviado desde el formulario de contacto
  accDescr {
    El visitante rellena el formulario en el sitio y lo envía mediante una petición HTTP
    normal, sin que el sitio necesite JavaScript propio. El proveedor, con servidores en
    Alemania, aplica su filtro antispam y el campo trampa, y responde con una página propia
    de verificación. Allí el navegador resuelve por su cuenta un desafío criptográfico y a
    la persona solo se le pide confirmar con un clic. Después el proveedor entrega el
    mensaje al buzón de correo y redirige al visitante a la página de agradecimiento.
  }
  participant V as Visitante
  participant W as carloslb.com
  participant F as Proveedor (Alemania)
  participant M as Buzón de correo
  V-&gt;&gt;W: rellena y envía el formulario
  W-&gt;&gt;F: petición HTTP normal, sin JavaScript propio
  F-&gt;&gt;F: filtro antispam y campo trampa
  F--&gt;&gt;V: página propia de verificación
  Note over V,F: El navegador resuelve el desafío.&lt;br /&gt;A la persona solo se le pide un clic
  V-&gt;&gt;F: confirma con un clic
  F-&gt;&gt;M: entrega el mensaje
  F--&gt;&gt;V: redirige a la página de agradecimiento
  Note over V,F: Ningún recurso del proveedor se incrusta en el sitio
</code></pre>

<p>Ese detalle del captcha es la clave: <strong>se sirve en la página del proveedor, después de pulsar enviar.</strong> En mi sitio no se incrusta nada suyo, y el formulario se envía con HTML puro, sin JavaScript propio.</p>

<p>Y el tipo de captcha importa tanto como dónde vive. Aquí no hay que descifrar texto deformado ni señalar semáforos: el navegador resuelve por su cuenta un desafío criptográfico y a la persona solo se le pide confirmar con un clic. Eso no es solo comodidad, es accesibilidad. Los captchas de imagen son una barrera conocida para quien tiene baja visión o navega con lector de pantalla; al hacer el trabajo el dispositivo en vez de la persona, no queda ninguna prueba visual ni de audio que superar.</p>

<p>Descarté un competidor por tener el tratamiento en Estados Unidos y usar cookies, y otro —libre y muy elegante— porque exigía un servidor propio que no tengo.</p>

<p>Dos decisiones más sobre el formulario:</p>

<p><strong>Minimización de datos.</strong> Nombre, correo, asunto y mensaje. Nada de teléfono ni de actividad económica. Si no lo necesito para responderte, no te lo pido. Es lo que exige el reglamento, pero además es de sentido común: los datos que no tienes no se te pueden filtrar.</p>

<p><strong>Bloquear el botón de enviar hasta que el formulario sea válido: descartado por accesibilidad.</strong> Es un patrón muy extendido y es malo. Un botón deshabilitado no explica por qué lo está, y como no recibe el foco del teclado, un lector de pantalla ni siquiera lo anuncia. La persona se queda delante de un formulario que no responde y sin ninguna pista. La validación nativa del navegador ya avisa y lleva el foco al campo problemático. Menos código y más accesible.</p>

<h2 id="qué-normativa-aplica-de-verdad">Qué normativa aplica de verdad</h2>

<p>Aquí entra mi otro oficio. Antes de copiar una plantilla legal, hice el análisis de qué me obliga y qué no. <em>Esta es mi lectura de mi propio caso, no asesoramiento jurídico: si tu situación es otra, el resultado cambia.</em></p>

<pre><code class="language-mermaid">flowchart TD
  accTitle: Qué normativa aplica a una web personal sin actividad económica
  accDescr {
    Cuatro comprobaciones. El Real Decreto 1112 de 2018 sobre accesibilidad solo alcanza
    al sector público, así que no aplica. La Ley 11 de 2023 no aplica al no haber comercio
    electrónico. La LSSI-CE no aplica al no haber actividad económica, de modo que ni el
    aviso legal ni la política de cookies son obligatorios. En cambio, el Reglamento General
    de Protección de Datos sí aplica, porque el formulario de contacto recoge datos
    personales: la política de privacidad es obligatoria.
  }
  A["¿Es un sitio del sector público?"] --&gt;|No| A2["RD 1112/2018: no aplica"]
  B["¿Hay comercio electrónico?"] --&gt;|No| B2["Ley 11/2023: no aplica"]
  C["¿Hay actividad económica?"] --&gt;|No| C2["LSSI-CE: no aplica&lt;br/&gt;Aviso legal y política de cookies&lt;br/&gt;no son obligatorios"]
  D["¿Se recogen datos personales?"] --&gt;|"Sí: el formulario"| D2["RGPD: sí aplica&lt;br/&gt;Política de privacidad obligatoria"]
</code></pre>

<p>Resultado: de los cuatro documentos que todo el mundo copia, <strong>solo uno era exigible</strong>. Mantengo los otros por criterio propio —prefiero pecar de exceso—, pero con una regla:</p>

<blockquote>
  <p><strong>Conservar el documento no es lo mismo que conservar la ficción.</strong></p>
</blockquote>

<p>Las plantillas legales vienen infladas. La que descargué declaraba tratamientos con fines comerciales, estadísticos y de análisis de navegación. Nada de eso existe aquí. Y declarar un tratamiento inexistente hace daño dos veces: desinforma a quien lee, y te compromete con bases jurídicas que no tienes. Así que podé todo lo que no era verdad.</p>

<p>Lo mismo con los datos que publico de mí: nombre y correo. Sin DNI, sin domicilio, sin teléfono. La plantilla los pedía; la ley, en mi caso, no. Por coherencia, los commits del repositorio van con la dirección de reenvío anónimo de GitHub, y en los datos estructurados de las páginas no hay correo electrónico: los recolectores de direcciones rastrean precisamente ahí.</p>

<p>Puedes leer el resultado en la <a href="/privacy.html">política de privacidad</a> y en la <a href="/cookiespolicy.html">política de cookies</a>. Esta última la reescribí entera desde cero, porque una plantilla que empieza explicando qué cookies usa no sirve para decir que no usas ninguna.</p>

<hr />

<p><strong>Siguiente:</strong> en la tercera parte, lo que casi rompe el sitio sin dar ni un error, cómo se audita la accesibilidad de verdad, y por qué el constructor de GitHub Pages no me servía.</p>]]></content><author><name></name></author><category term="it" /><category term="admin" /><category term="Jekyll" /><category term="RGPD" /><category term="protección de datos" /><category term="privacidad" /><category term="formulario de contacto" /><category term="cookies" /><category term="autoalojado" /><category term="LSSI-CE" /><summary type="html"><![CDATA[Cero recursos de terceros, un formulario que funciona sin JavaScript y un análisis honesto de qué normativa aplica de verdad a una web personal.]]></summary></entry><entry xml:lang="es"><title type="html">Cómo se hizo esta web (1): una persona, tres oficios, un solo sitio</title><link href="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/" rel="alternate" type="text/html" title="Cómo se hizo esta web (1): una persona, tres oficios, un solo sitio" /><published>2026-08-02T09:00:00+00:00</published><updated>2026-08-02T09:00:00+00:00</updated><id>https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos</id><content type="html" xml:base="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/"><![CDATA[<p>Tengo tres oficios y una sola cara. Trabajo con sistemas y redes, llevo contabilidad y trámites, y doy formación. Un portafolio que contara solo uno de los tres mentiría por omisión; tres portafolios separados serían tres mantenimientos y ninguno al día. Esta serie cuenta cómo salí de ahí, qué decidí y —sobre todo— qué descarté y por qué.</p>

<p>Empecé a primeros de junio de 2026. El sitio se publicó el 22 de julio. En medio hay unas cuantas decisiones que no se ven, que son justo las que quiero contar: <strong>el valor de un portafolio no está en la lista de tecnologías, está en los descartes razonados.</strong></p>

<p>Todo el código está a la vista: <a href="https://github.com/carloslb-com/carloslb-com.github.io">github.com/carloslb-com/carloslb-com.github.io</a>.</p>

<h2 id="el-principio-que-ordenó-el-resto">El principio que ordenó el resto</h2>

<p>Antes de escribir una línea me puse un límite: <strong>tener algo que enseñar, no ser preciso al 99,9 %.</strong> Un portafolio perfecto que no se publica no es un portafolio, es una carpeta. Y un lema de trabajo: <strong>robustez por encima de brevedad o ingenio.</strong> Cada vez que dudé entre la solución lista y la solución aburrida que aguanta, gana la aburrida.</p>

<h2 id="el-stack-y-por-qué">El stack, y por qué</h2>

<table>
  <thead>
    <tr>
      <th>Pieza</th>
      <th>Elección</th>
      <th>Razón</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Generador</td>
      <td>Jekyll 4.4.1 (Ruby 3.3.8)</td>
      <td>Sitio estático: sin base de datos y sin panel que atacar</td>
    </tr>
    <tr>
      <td>Estilos</td>
      <td>Dart Sass con <code class="language-plaintext highlighter-rouge">@use</code> / <code class="language-plaintext highlighter-rouge">@forward</code></td>
      <td>Módulos de verdad; <code class="language-plaintext highlighter-rouge">@import</code> está obsoleto</td>
    </tr>
    <tr>
      <td>Bilingüe</td>
      <td>jekyll-polyglot</td>
      <td>Dos árboles (<code class="language-plaintext highlighter-rouge">/</code> y <code class="language-plaintext highlighter-rouge">/en/</code>) sin duplicar plantillas</td>
    </tr>
    <tr>
      <td>Extras</td>
      <td>jekyll-feed, jekyll-seo-tag, jekyll-sitemap</td>
      <td>Lo estándar, sin inventar</td>
    </tr>
    <tr>
      <td>Entorno</td>
      <td>Máquina virtual Ubuntu en VirtualBox</td>
      <td>Desarrollo aislado del equipo de trabajo</td>
    </tr>
    <tr>
      <td>Publicación</td>
      <td>GitHub Pages con flujo propio de GitHub Actions</td>
      <td>Por obligación, no por gusto: lo cuento en la tercera parte</td>
    </tr>
  </tbody>
</table>

<p>Lo importante de la tabla es lo que <strong>no</strong> hay: ni base de datos, ni CMS, ni servicios de terceros incrustados. Un sitio estático es un montón de archivos HTML. No tiene sesión que robar ni consulta que inyectar.</p>

<h2 id="una-persona-tres-mundos">Una persona, tres mundos</h2>

<p>La estructura es la misma en los tres perfiles. Cabecera, secciones, blog, pie: idénticos. Lo único que cambia es la <strong>piel</strong>: la paleta y la tipografía.</p>

<pre><code class="language-mermaid">flowchart TD
  accTitle: Estructura del sitio, con una portada neutral y tres perfiles
  accDescr {
    La portada es un espacio neutral con tres puertas de entrada: IT y Telecomunicaciones,
    Administración y Recursos Humanos, y Formación y Docencia. Las tres desembocan en la
    misma estructura de página, y lo único que cambia entre ellas son los tokens de CSS
    que definen la paleta de color y la tipografía.
  }
  H["Portada neutral&lt;br/&gt;(tres puertas)"] --&gt; IT["IT / Telecomunicaciones"]
  H --&gt; AD["Administración / RRHH"]
  H --&gt; FO["Formación / Docencia"]
  IT --&gt; B["Misma estructura:&lt;br/&gt;cabecera, secciones, blog, pie"]
  AD --&gt; B
  FO --&gt; B
  B --&gt; T["Solo cambian los tokens de CSS:&lt;br/&gt;paleta y tipografía"]
</code></pre>

<p>Son <strong>ocho paletas</strong>: cuatro mundos (los tres perfiles más la portada neutral) por dos temas, claro y oscuro. Las validé a alto contraste —la mayoría en nivel AAA— <em>antes</em> de escribir una sola línea de contenido. Corregir el contraste al final es rehacerlo todo.</p>

<p>La tipografía va autoalojada en <code class="language-plaintext highlighter-rouge">woff2</code>. El cuerpo es <strong>Atkinson Hyperlegible</strong>, diseñada específicamente para baja visión. Los titulares cambian por mundo: JetBrains Mono en IT, IBM Plex Serif en Administración, Nunito en Formación. Un tipo de letra dice mucho antes de que se lea la primera palabra.</p>

<p>Los datos —proyectos, certificaciones, cursos, experiencia— viven en YAML, con el patrón <code class="language-plaintext highlighter-rouge">clave → {es, en}</code>. Se traduce solo lo que cambia entre idiomas. Un año es un año en los dos.</p>

<h3 id="una-decisión-pequeña-con-consecuencias-grandes">Una decisión pequeña con consecuencias grandes</h3>

<p>La clase del perfil vive en la etiqueta <code class="language-plaintext highlighter-rouge">html</code>, no en <code class="language-plaintext highlighter-rouge">body</code>, junto al atributo del tema. Si vive en <code class="language-plaintext highlighter-rouge">body</code>, el navegador ya ha empezado a pintar cuando aplicas el tema y se ve el destello de la paleta equivocada. Con la clase arriba, el guion se ejecuta antes de que haya nada que destellar.</p>

<p>Efecto secundario: como los dos atributos cuelgan del mismo elemento, los selectores de paleta se concatenan <strong>sin espacio</strong>. Sí, el CSS también tiene su letra pequeña.</p>

<h2 id="el-color-puede-depender-de-javascript-la-navegación-no">El color puede depender de JavaScript; la navegación, no</h2>

<p>Aquí está la decisión de la que más orgulloso estoy, y es la que más cosas descartó.</p>

<p>El requisito era: si entras al blog por la puerta de IT, el artículo debería <em>sentirse</em> de IT. Pero un artículo es un solo archivo, y puede alcanzarse desde tres sitios distintos y en dos idiomas.</p>

<p><strong>Camino 1: generar versiones físicas del artículo por contexto.</strong> Tres contextos por dos idiomas son seis versiones de cada artículo. Mantenimiento inviable y URLs duplicadas compitiendo entre sí. Descartado.</p>

<p><strong>Camino 2: que JavaScript reconstruya la barra de navegación al vuelo.</strong> Más código, más frágil, y sin JavaScript el visitante se queda sin poder navegar. Descartado.</p>

<p><strong>Lo que hice:</strong> el color y las tipografías sí cambian según la puerta por la que entraste; la barra de navegación se queda como está.</p>

<pre><code class="language-mermaid">sequenceDiagram
  accTitle: Cómo el artículo adopta el color de la puerta por la que se entró
  accDescr {
    Desde el listado del blog de IT, el enlace al artículo lleva un parámetro que indica
    la procedencia. El artículo se sirve con la piel neutra. Un guion situado en la cabecera
    del documento lee ese parámetro antes de que el navegador pinte nada, aplica la clase
    del perfil correspondiente y limpia el parámetro de la barra de direcciones. La barra
    de navegación no cambia en ningún momento.
  }
  participant L as Listado del blog de IT
  participant N as Navegador
  participant A as Artículo
  L-&gt;&gt;N: enlace con el parámetro de procedencia
  N-&gt;&gt;A: solicita el artículo (se sirve con la piel neutra)
  A-&gt;&gt;A: un guion en la cabecera lee el parámetro antes de pintar
  A-&gt;&gt;N: aplica la piel de IT y limpia la dirección
  Note over N,A: La barra de navegación no cambia nunca
</code></pre>

<p>Si el guion falla, el artículo se ve en neutro y se lee perfectamente. Nadie se queda fuera.</p>

<p>Dos apuntes que me llevo de ahí, y que ya he reutilizado en otros sitios:</p>

<blockquote>
  <p><strong>Cuando todas las soluciones a un requisito son malas, a veces el problema es el requisito, no la solución.</strong></p>
</blockquote>

<blockquote>
  <p><strong>Busca la versión que da la mayor parte del valor percibido por una fracción del coste.</strong> El color da el 90 % de la sensación de estar en un mundo distinto. La barra dinámica añadía un 10 % a un coste enorme.</p>
</blockquote>

<h2 id="cómo-he-trabajado-esto">Cómo he trabajado esto</h2>

<p>No lo he hecho solo. He usado un asistente de inteligencia artificial como herramienta de consulta, de aprendizaje de Jekyll y Liquid, de diagnóstico cuando algo no cuadraba, y de organización y seguimiento de las tareas de un proyecto que ha durado dos meses y tiene muchas piezas.</p>

<p>Lo digo con todas las letras porque esconderlo sería justo lo contrario de lo que defiendo. Y porque el matiz importa: la herramienta acelera la consulta y ordena el trabajo, pero no toma las decisiones. Las de arriba —qué descartar, qué requisito cuestionar, dónde parar— son mías, y cada una tiene su porqué escrito. En la tercera parte cuento en qué se equivocó y cómo lo detecté.</p>

<hr />

<p><strong>Siguiente:</strong> en la segunda parte, por qué este sitio no carga ni un solo recurso de un tercero, cómo funciona el formulario de contacto sin JavaScript, y qué normativa aplica de verdad a una web personal —que es bastante menos de lo que dicen las plantillas.</p>]]></content><author><name></name></author><category term="it" /><category term="Jekyll" /><category term="Liquid" /><category term="sitio estático" /><category term="Sass" /><category term="sitio bilingüe" /><category term="arquitectura web" /><category term="portafolio" /><summary type="html"><![CDATA[Por qué un perfil híbrido no cabe en un portafolio monotemático, y cómo se resuelve con tres mundos que comparten estructura y solo cambian de piel.]]></summary></entry></feed>