<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>Rootdevs · Blog</title>
<link>https://rootdevs.es/blog</link>
<description>Posts técnicos de Francisco Javier Antúnez Durán sobre Angular, rendimiento, herramientas e IA.</description>
<language>es</language>
<lastBuildDate>Tue, 27 Oct 2026 00:00:00 GMT</lastBuildDate>
<atom:link href="https://rootdevs.es/rss/es.xml" rel="self" type="application/rss+xml"/>
<item><title>Cómo construí Framed by Santi: de cero a producción en tres días.</title><link>https://rootdevs.es/blog/how-i-built-framed-by-santi</link><guid isPermaLink="true">https://rootdevs.es/blog/how-i-built-framed-by-santi</guid><pubDate>Tue, 27 Oct 2026 00:00:00 GMT</pubDate><description>Un portfolio de fotografía con panel privado, tres idiomas y 100 en Lighthouse, construido con Claude Code como asistente: el stack, las features, los problemas reales y el método para ir rápido sin perder calidad.</description><category>Angular</category><category>Firebase</category><category>Performance</category><category>AI</category><media:content medium="image" type="image/webp" url="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_ES_dark.webp"/><enclosure length="74356" type="image/webp" url="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_ES_dark.webp"/><content:encoded><![CDATA[<p><img alt="Cómo construí Framed by Santi: de cero a producción en tres días." src="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_ES_dark.webp"></p><h2>📸 Introducción: un portfolio de fotografía en producción al tercer día</h2>
<p><a href="https://framedbysanti.rootdevs.es">Framed by Santi</a> es la web de <a href="https://www.instagram.com/framedbysanti">@framedbysanti</a>, un fotógrafo aficionado que dispara con una Fujifilm X100VI por las calles de Nueva York. Tiene dos caras: una web pública para enseñar sus fotos y un panel privado para que él mismo publique sin depender de nadie.</p>
<p>Empezó como un regalo para un amigo, pero este post no va de eso: va de <strong>cómo la construí en muy poco tiempo, asistido por Claude Code, sin renunciar a la calidad</strong>.</p>
<p>Las cifras, sacadas del repositorio:</p>
<ul>
<li>📅 <strong>9 días con commits</strong>, del 13 al 26 de octubre de 2026.</li>
<li>🚀 <strong>En producción el día 3</strong>: web pública y panel privado.</li>
<li>🔀 <strong>189 commits</strong> y <strong>35 pull requests</strong>; cada cambio lo probé yo, como visitante y como admin, antes de llegar a producción.</li>
<li>🧪 <strong>117 archivos de test</strong> con <strong>857 casos</strong>.</li>
<li>🌍 <strong>Tres idiomas</strong>: inglés, español y portugués.</li>
<li>💯 <strong>100 en las cuatro categorías de Lighthouse</strong> (escritorio, red 4G).</li>
</ul>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/framedBySanti/commits_ES_dark.webp" alt="Gráfico de barras de commits por día del 13 al 26 de octubre de 2026: 1, 43, 75 (día de la subida a producción), 3, ninguno el fin de semana, 22, 7, 23, 3, ninguno del 23 al 25 y 12"><hr>
<h2>🤖 Cómo lo trabajé: agentes de IA, pero con método</h2>
<p>La construí en la terminal con agentes de IA (Claude Code) configurados con <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>, la herramienta de código abierto de <a href="https://www.linkedin.com/in/alanbuscaglia/">Alan Buscaglia</a> (Gentleman Programming). No es &quot;pedirle una web a la IA&quot;: es un flujo de trabajo con reglas.</p>
<ul>
<li><strong>ODD (Organic-Driven Development):</strong> los cambios pequeños se hacen directamente; el trabajo grande lleva un documento de tareas con los requisitos copiados literalmente, para que nada se pierda entre sesiones.</li>
<li><strong>Engram:</strong> memoria persistente. Cada decisión, error resuelto o preferencia queda guardada y el agente la recupera en la siguiente sesión. Por ejemplo: &quot;pregunta antes de hacer push, incluso a staging&quot;.</li>
<li><strong>Tests primero:</strong> para cada regla nueva, primero un test que falla y después el código que lo hace pasar.</li>
<li><strong>Ramas y revisión:</strong> cada cambio sale de una rama, sube a <code>develop</code>, lo pruebo en <code>staging</code> y solo llega a <code>main</code> (producción) cuando yo lo apruebo.</li>
</ul>
<p>El reparto fue claro:</p>
<ul>
<li>🤖 <strong>El agente:</strong> escribió el código y los tests, investigó los errores, propuso soluciones y abrió los PR.</li>
<li>🙋 <strong>Yo:</strong> decidí qué construir y cómo tenía que funcionar, revisé cada cambio, corregí al agente cuando se equivocaba y aprobé cada subida a producción.</li>
</ul>
<blockquote>
<p>💡 La IA escribe rápido; lo que hace que el resultado sea bueno es <strong>verificar</strong>. Cada PR pasó lint, tests y build, y ningún cambio llegó a producción sin mi aprobación.</p>
</blockquote>
<hr>
<h2>🧱 Stack y arquitectura</h2>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/framedBySanti/architecture_ES_dark.webp" alt="Diagrama de arquitectura de Framed by Santi: el navegador recibe la app estática de Vercel, lee Firestore por REST en las páginas públicas y usa Auth y el SDK de Firebase solo en el panel; las fotos llegan desde la CDN de Cloudinary y el panel las sube directamente con una firma; las funciones de Vercel limpian Cloudinary, envían el email con Resend, traducen con DeepL y firman las subidas, y todas las del panel comprueban en Firestore que el usuario es admin con su propio token"><p><strong>Frontend</strong></p>
<ul>
<li><strong>Angular 22</strong> sin Zone.js (<em>zoneless</em>), con <strong>Signals</strong> para todo el estado y componentes <em>standalone</em>.</li>
<li><strong>TypeScript 6</strong> en modo estricto: sin <code>any</code> y con tipos de retorno explícitos.</li>
<li><strong>CSS nativo</strong> con <em>nesting</em>, BEM y <em>mobile-first</em>. Sin preprocesador.</li>
<li><strong>Vite 8</strong> con <code>@analogjs/vite-plugin-angular</code> como única herramienta de build.</li>
<li><strong>Transloco</strong> para los tres idiomas.</li>
</ul>
<p><strong>Servicios</strong></p>
<ul>
<li><strong>Vercel:</strong> hosting y cuatro funciones <em>serverless</em>.</li>
<li><strong>Firebase Auth y Firestore:</strong> login del panel y datos (series, temas, ajustes).</li>
<li><strong>Cloudinary:</strong> almacenamiento y entrega de fotos, con subidas firmadas.</li>
<li><strong>DeepL:</strong> traducción automática de los textos.</li>
<li><strong>Resend:</strong> el email del formulario de contacto.</li>
</ul>
<p><strong>Las cuatro funciones de <code>api/</code></strong></p>
<table>
<thead>
<tr>
<th>Función</th>
<th>Qué hace</th>
</tr>
</thead>
<tbody><tr>
<td><code>sign-upload</code></td>
<td>Comprueba que quien llama es admin y firma la subida a Cloudinary.</td>
</tr>
<tr>
<td><code>translate</code></td>
<td>Proxy a DeepL, solo para admins, con 10 segundos de límite.</td>
</tr>
<tr>
<td><code>cleanup-photos</code></td>
<td>Borra de Cloudinary las fotos que ya no usa nada.</td>
</tr>
<tr>
<td><code>contact</code></td>
<td>Valida el formulario y envía el email con Resend.</td>
</tr>
</tbody></table>
<p>Las tres del panel comprueban que el usuario es admin de la misma forma: leen su documento en Firestore <strong>con el token del propio usuario</strong>, así que son las reglas de seguridad de Firestore las que deciden, no un secreto en el servidor.</p>
<hr>
<h2>🖼️ La web pública</h2>
<ul>
<li><strong>Las publicaciones son series</strong>, como un carrusel de Instagram: varias fotos con un título y una historia.</li>
<li><strong>Home</strong> con la última serie en un carrusel que se puede arrastrar.</li>
<li><strong>Galería</strong> con una sección por serie y filtro por temas.</li>
<li><strong>Visor a pantalla completa:</strong> zoom y desplazamiento (pellizcar, arrastrar, doble toque, rueda), gestos para pasar de foto, miniaturas, precarga de la siguiente foto y un fundido entre fotos.</li>
<li><strong>View Transitions</strong> entre páginas, con un efecto de iris como el diafragma de una cámara.</li>
<li><strong>Sobre mí</strong> con la historia de Santi en sus palabras y un formulario de contacto. Su email nunca llega a la página.</li>
<li><strong>Idiomas de verdad:</strong> fechas, números y plurales siguen el idioma activo (<code>en-US</code>, <code>es-ES</code>, <code>pt-PT</code>).</li>
<li>Con <code>prefers-reduced-motion</code> no se anima nada.</li>
</ul>
<p align="center"><a href="https://framedbysanti.rootdevs.es/" target="_blank" rel="noopener"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/home_ES.webp" alt="Portada de Framed by Santi: foto del Flatiron a pantalla completa con el nombre de Santi y los botones a la galería y a Instagram" width="720"></a><br><em>La home. Haz clic para verla en vivo.</em></p><p align="center"><a href="https://framedbysanti.rootdevs.es/gallery" target="_blank" rel="noopener"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/gallery_ES.webp" alt="Galería de Framed by Santi: filtro por temas y una serie con su carrusel de fotos" width="720"></a><br><em>La galería con el filtro por temas.</em></p><p align="center"><a href="https://framedbysanti.rootdevs.es/gallery" target="_blank" rel="noopener"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/viewer_ES.webp" alt="Visor de Framed by Santi: una foto en grande con flechas, controles de zoom y la tira de miniaturas" width="720"></a><br><em>El visor: zoom, flechas y miniaturas.</em></p><hr>
<h2>🔐 El panel privado</h2>
<p>El panel vive en <code>/admin</code> y no tiene ningún enlace público: se llega solo escribiendo la ruta.</p>
<ul>
<li><strong>Biblioteca de series:</strong> interruptor de publicada o borrador, editar y borrar.</li>
<li><strong>Editor de series:</strong> arrastrar fotos, previsualizarlas, reordenarlas y escribir los textos en una pestaña por idioma.</li>
<li><strong>Fotos preparadas en el navegador:</strong> antes de subir, cada foto se redimensiona y se recomprime hasta un máximo de 6000 px y 10 MB, bajando la calidad del JPEG por pasos hasta que cabe. Así se respetan los límites del plan gratuito de Cloudinary sin que Santi tenga que pensar en ello.</li>
<li><strong>Traducción automática:</strong> Santi escribe en un idioma y, al publicar, DeepL rellena los que faltan. Cada idioma se puede corregir a mano.</li>
<li><strong>Temas:</strong> se crean y se borran desde el panel.</li>
<li><strong>Limpieza automática:</strong> al abrir la biblioteca, se borran en segundo plano las fotos de Cloudinary que ya no usa ninguna serie.</li>
<li><strong>Ajustes:</strong> foto de perfil y cambio de contraseña.</li>
<li><strong>También en el móvil:</strong> el panel se adapta a la pantalla del teléfono, y en el login se puede mostrar u ocultar la contraseña.</li>
<li><strong>Y un <em>easter egg</em></strong> solo para el admin: un rincón para seguir con fuerzas. 💪</li>
</ul>
<p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/dashboard-library_ES.webp" alt="Biblioteca del panel privado: las series con su portada, el filtro por publicadas y borradores, y el interruptor para publicar" width="720"><br><em>La biblioteca: cada serie se publica o se pasa a borrador con un interruptor.</em></p><p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/dashboard-editor_ES.webp" alt="Editor de series del panel privado: las fotos de la serie a la izquierda y, a la derecha, los textos con una pestaña por idioma y el botón Traducir" width="720"><br><em>El editor: fotos a la izquierda, textos por idioma y el botón para traducir a la derecha.</em></p><hr>
<h2>⚡ Rendimiento: medir, recortar y volver a medir</h2>
<p>El rendimiento no lo dejé para el final: medí con Lighthouse, ataqué lo que más pesaba y volví a medir. Así queda hoy en escritorio con red 4G:</p>
<p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/lighthouse.webp" alt="Informe de Lighthouse de Framed by Santi en escritorio con red 4G: 100 en Performance, Accessibility, Best Practices y SEO" width="420"></p><h3>Primera ronda: de 44 a 73 en móvil</h3>
<p>La primera medición, el 15 de octubre en móvil con 4G lenta (el caso más exigente), dio un rendimiento de <strong>44-48</strong>, con un CLS de <strong>0,333</strong> y un LCP de <strong>7,5 s</strong>. Con tres cambios lo arreglé casi todo:</p>
<ul>
<li><strong>Precargar la foto principal de la home</strong> desde <code>index.html</code>, con <code>fetchpriority=&quot;high&quot;</code>. El script de precarga va antes de cualquier hoja de estilos, porque una hoja pendiente bloquea los scripts que vienen detrás.</li>
<li><strong>La home va en el paquete principal</strong>, sin carga diferida, para no añadir un viaje de red antes de pintar la primera página.</li>
<li><strong>El footer se pinta después de la primera navegación.</strong> Era el responsable de todo el CLS. El carrusel de la última serie también reserva su espacio mientras carga.</li>
</ul>
<p>Resultado en móvil: <strong>rendimiento 73, CLS 0,001 y LCP 5,6 s</strong>.</p>
<h3>Segunda ronda: quitar lo que no hacía falta</h3>
<ul>
<li><strong>Fuentes propias.</strong> Las sirvo desde el propio dominio, sin la ida y vuelta a Google Fonts.</li>
<li><strong>Firebase fuera de las páginas públicas.</strong> Al revisar el build vi que el paquete de Firebase pesaba <strong>563 kB (168 kB comprimidos)</strong> y se precargaba en todas las páginas, y que cada visita abría además un iframe de autenticación de Google de unos <strong>93 KiB</strong>. Todo eso para leer unas series que son públicas. Ahora las páginas públicas leen Firestore con <code>fetch</code> contra su API REST, con las mismas reglas de seguridad.</li>
<li><strong>El login solo se carga si hace falta:</strong> una marca local indica que en ese navegador ya entró un admin. Los visitantes nunca descargan Firebase Auth. Y la marca no da ningún permiso: la comprobación real sigue siendo Firebase.</li>
</ul>
<p>Resultado en escritorio con red 4G: <strong>100 en Performance, Accessibility, Best Practices y SEO</strong>.</p>
<h3>Lo que viene de serie</h3>
<ul>
<li><strong>Imágenes responsive:</strong> cada foto se sirve desde Cloudinary con <code>srcset</code> en cinco anchos (480, 800, 1200, 1600 y 2400 px) y formato y calidad automáticos.</li>
<li><strong>Cada página se carga cuando se visita</strong> (<em>lazy loading</em>), salvo la home.</li>
</ul>
<blockquote>
<p>💡 La lección: <strong>medir antes de optimizar</strong>. El mayor problema de CLS era un footer, y el mayor peso, una librería que los visitantes ni siquiera usaban.</p>
</blockquote>
<hr>
<h2>🛡️ Seguridad</h2>
<ul>
<li><strong>Reglas de Firestore que deniegan todo por defecto.</strong> El público solo puede leer las series publicadas, y los ajustes solo aceptan los campos permitidos.</li>
<li><strong>Subidas firmadas:</strong> el navegador nunca tiene permiso para subir a Cloudinary por su cuenta; cada subida la firma una función que antes comprueba que eres admin.</li>
<li><strong>Formulario de contacto</strong> con límites de longitud, HTML escapado y un campo trampa: los bots lo rellenan, reciben un &quot;OK&quot; y no se envía nada.</li>
<li><strong>Staging no se indexa:</strong> los builds de staging llevan <code>noindex</code> y un <code>robots.txt</code> que lo prohíbe todo.</li>
</ul>
<hr>
<h2>🧪 Calidad: el orden no se negocia, lo impone el linter</h2>
<p>Cuando la IA escribe la mayor parte del código, las convenciones no pueden depender de que alguien se acuerde de ellas. Por eso las convertí en reglas: si el código no sigue el orden, <strong>el lint falla</strong>, da igual quién lo haya escrito. Las reglas están desde el primer día del proyecto.</p>
<p><strong>Cinco reglas de ESLint propias</strong>, escritas para este proyecto:</p>
<table>
<thead>
<tr>
<th>Regla</th>
<th>Qué obliga</th>
<th>¿Se arregla sola?</th>
</tr>
</thead>
<tbody><tr>
<td><code>component-decorator-order</code></td>
<td>Las propiedades de <code>@Component</code> siempre en el mismo orden: <code>standalone</code>, <code>changeDetection</code>, <code>imports</code>, <code>selector</code>, <code>styleUrl</code>, <code>templateUrl</code>...</td>
<td>✅</td>
</tr>
<tr>
<td><code>inject-before-signals</code></td>
<td>Las llamadas a <code>inject()</code> van antes que cualquier otra propiedad de la clase.</td>
<td>✅</td>
</tr>
<tr>
<td><code>alphabetical-component-imports</code></td>
<td>El array <code>imports</code> del componente, en orden alfabético.</td>
<td>✅</td>
</tr>
<tr>
<td><code>alphabetical-inputs-signals</code></td>
<td>Los <code>input()</code> y los <code>signal()</code>, en orden alfabético dentro de su grupo.</td>
<td>❌</td>
</tr>
<tr>
<td><code>alphabetical-interface-members</code></td>
<td>Los miembros de interfaces y tipos, en orden alfabético.</td>
<td>❌</td>
</tr>
</tbody></table>
<p>Las dos últimas solo avisan, no reordenan solas: en las interfaces, por ejemplo, mover miembros puede romper código que usa <em>spread</em> u <code>Object.assign</code>, así que prefiero que lo decida una persona.</p>
<p><strong>Y el resto del orden, también automático:</strong></p>
<ul>
<li><strong>Imports</strong> agrupados y ordenados: primero Angular y librerías externas, luego los alias internos (<code>@core</code>, <code>@features</code>...) y al final los relativos.</li>
<li><strong>Claves de objetos</strong> en orden alfabético en datos, constantes, modelos e interfaces.</li>
<li><strong>JSON de traducción</strong> con las claves ordenadas, sin comentarios.</li>
<li><strong>Prettier</strong> con plugins que ordenan las propiedades CSS, los atributos HTML y las claves de los JSON.</li>
<li><strong>TypeScript estricto:</strong> <code>any</code> es un error, los tipos de retorno son obligatorios y se usa siempre <code>===</code>.</li>
</ul>
<h3>El patrón diccionario</h3>
<p>Hay una práctica que pido siempre y que no impone ningún linter: el <strong>patrón diccionario</strong>, que en inglés se conoce como <em>object lookup</em> (y como <em>dispatch table</em> cuando los valores son funciones). Cuando una función traduce un valor a otro, nada de <code>switch</code> ni de <code>if/else</code> encadenados: un objeto tipado con <code>Record</code>, acceso por clave y un valor por defecto con <code>??</code>.</p>
<pre><code class="language-typescript">const TRANSLATE_ERRORS: Record&lt;TranslationError[&#39;reason&#39;], string&gt; = {
  failed: &#39;admin.editor.translate.errors.failed&#39;,
  limit: &#39;admin.editor.translate.errors.limit&#39;,
  &#39;not-allowed&#39;: &#39;admin.editor.translate.errors.notAllowed&#39;,
};
</code></pre>
<p>Tres ventajas:</p>
<ul>
<li><strong>Se lee de un vistazo:</strong> todos los casos en una tabla, en lugar de repartidos entre ramas.</li>
<li><strong>TypeScript vigila que no falte ninguno:</strong> con <code>Record&lt;TranslationError[&#39;reason&#39;], string&gt;</code>, si mañana aparece un motivo de error nuevo, el código no compila hasta que tenga su texto.</li>
<li><strong>Añadir un caso es añadir una línea</strong>, sin tocar la lógica.</li>
</ul>
<p>Si en vez de un valor necesitas ejecutar algo, los valores pasan a ser funciones: es la <em>dispatch table</em>. Aquí hay una trampa que yo mismo tenía en mis apuntes:</p>
<pre><code class="language-javascript">const LOKI_DEFAULT_DISFRAZ = &#39;Loki&#39;;

const LOKI_DISFRACES = {
  Hulk: () =&gt; &#39;Thanos&#39;,
  Thor: () =&gt; &#39;Odin&#39;,
};

// ❌ Si el enemigo no está en la tabla: TypeError, ... is not a function
const disfraz = LOKI_DISFRACES[enemy]() ? LOKI_DISFRACES[enemy]() : LOKI_DEFAULT_DISFRAZ;

// ✅ Con ?.() no se ejecuta nada si no existe, y ?? pone el valor por defecto
const disfraz = LOKI_DISFRACES[enemy]?.() ?? LOKI_DEFAULT_DISFRAZ;
</code></pre>
<p>La primera versión, además de romperse con una clave desconocida, ejecuta la función dos veces. Y <code>??</code> es mejor que el ternario: solo usa el valor por defecto cuando la clave no existe, no cuando el resultado es un <code>&#39;&#39;</code> o un <code>0</code> válidos.</p>
<p>No es dogma: en las plantillas uso <code>@switch</code> cuando cada caso pinta algo distinto, y queda algún <code>switch</code> donde cada rama hace más que devolver un valor.</p>
<p><strong>Y los tests:</strong></p>
<ul>
<li><strong>857 casos</strong> en 117 archivos, con Vitest y JSDOM, también para las funciones de <code>api/</code>.</li>
<li><strong>Commits convencionales</strong> validados por un script.</li>
</ul>
<hr>
<h2>🐛 Cinco problemas reales (y cómo los resolví)</h2>
<h3>1. Pestañas que se rompían después de cada deploy</h3>
<p>Una pestaña abierta antes de un deploy pedía archivos del build anterior, que ya no existían. Las páginas que se cargan al visitarlas (galería, Sobre mí, el panel) <strong>no se abrían y no decían nada</strong>.</p>
<p>La solución fue un manejador de errores de navegación que, si falla la descarga de una parte de la app, recarga esa página desde cero para obtener el build nuevo. Solo lo hace para errores de descarga y una sola vez seguida por página, así que una caída real no puede provocar un bucle de recargas.</p>
<h3>2. Quitar Firebase de las páginas públicas</h3>
<p>El ahorro está contado en la sección de rendimiento; lo delicado fue la seguridad. Separé el login en un almacén que se carga bajo demanda, y la marca local que decide cuándo cargarlo <strong>no concede nada</strong>: si alguien la falsifica, solo consigue descargar Firebase Auth, porque la comprobación real sigue siendo el login y el documento de admin en Firestore.</p>
<h3>3. Las funciones de Vercel y los módulos ES</h3>
<p>Las funciones de <code>api/</code> fallaban en Vercel: como módulos ES nativos, los imports relativos necesitan la extensión <code>.js</code>. Además del arreglo, añadí un test que comprueba todos los imports de <code>api/</code> para que no vuelva a pasar.</p>
<h3>4. Una limpieza de fotos que no puede equivocarse</h3>
<p>Borrar fotos &quot;huérfanas&quot; es fácil; borrar solo las huérfanas, no tanto. La limpieza fue aprendiendo con cada caso:</p>
<ul>
<li>Si no puede leer las series, <strong>no borra nada</strong>. Una lectura fallida no significa que no haya fotos en uso.</li>
<li>Solo da por borradas las fotos que Cloudinary <strong>confirma</strong>.</li>
<li>Respeta la foto de perfil y las del rincón del admin, que viven en la misma carpeta.</li>
<li>No toca fotos de menos de 7 días y borra como mucho 100 por ejecución.</li>
</ul>
<h3>5. &quot;Guardando…&quot; para siempre</h3>
<p>Al guardar la foto de perfil con una conexión lenta, si el guardado tardaba demasiado la app preguntaba al servidor si había llegado... sin límite de tiempo para esa pregunta. Ahora esa comprobación tiene su propio límite de 5 segundos, y la lectura de la sesión guardada en IndexedDB se rinde a los 2 segundos, porque algunos navegadores nunca responden.</p>
<hr>
<h2>📅 Día a día</h2>
<table>
<thead>
<tr>
<th>Día</th>
<th>Commits</th>
<th>Qué pasó</th>
</tr>
</thead>
<tbody><tr>
<td>13 oct</td>
<td>1</td>
<td>Proyecto creado: Angular, i18n y herramientas.</td>
</tr>
<tr>
<td>14 oct</td>
<td>43</td>
<td>Prototipo completo de la web pública y fotos desde Cloudinary.</td>
</tr>
<tr>
<td>15 oct</td>
<td>75</td>
<td>Reglas de Firestore, login, subidas firmadas, editor, traducción, temas, limpieza, página 404. <strong>Primera subida a producción.</strong></td>
</tr>
<tr>
<td>16 oct</td>
<td>3</td>
<td>Fuentes propias.</td>
</tr>
<tr>
<td>19 oct</td>
<td>22</td>
<td>Páginas públicas sin el SDK, login bajo demanda, ajustes del panel.</td>
</tr>
<tr>
<td>20 oct</td>
<td>7</td>
<td>Historia de Sobre mí y ajustes en móvil.</td>
</tr>
<tr>
<td>21 oct</td>
<td>23</td>
<td>Icono de Instagram, el rincón del admin y el formulario de contacto.</td>
</tr>
<tr>
<td>22 oct</td>
<td>3</td>
<td>Pulido del email de contacto.</td>
</tr>
<tr>
<td>26 oct</td>
<td>12</td>
<td>Panel adaptado al móvil, mostrar la contraseña en el login y mejoras en el rincón del admin.</td>
</tr>
</tbody></table>
<hr>
<h2>🎯 Lecciones</h2>
<ol>
<li><strong>La velocidad sale del método, no solo de la IA.</strong> Requisitos por escrito, tests primero, ramas y revisión: sin eso, 75 commits en un día serían 75 problemas.</li>
<li><strong>La memoria persistente cambia el juego.</strong> Que el agente recuerde decisiones y errores de sesiones anteriores evita repetirlos.</li>
<li><strong>Verificar gana a generar.</strong> Nada llegó a producción sin pasar tests, lint y build.</li>
<li><strong>Los servicios gratuitos dan para mucho</strong> si diseñas pensando en sus límites (Cloudinary, Firebase, DeepL Free, Resend y Vercel).</li>
<li><strong>La seguridad no se delega en el frontend.</strong> Las reglas de Firestore y las funciones con comprobación de admin son las que protegen; la interfaz solo acompaña.</li>
</ol>
<blockquote>
<p>⭐ <strong>gentle-ai en GitHub:</strong> <a href="https://github.com/Gentleman-Programming/gentle-ai">github.com/Gentleman-Programming/gentle-ai</a>. Si te resulta útil, dale una estrella.</p>
</blockquote>
<p align="center"><a href="https://github.com/Gentleman-Programming/gentle-ai"><img width="220" src="https://raw.githubusercontent.com/Gentleman-Programming/gentle-ai/main/docs/assets/brand/built-with-gentle-ai.png" alt="Built with Gentle-AI" /></a></p>
]]></content:encoded></item>
<item><title>YouTube no reproduce en mi Mac: cinco pistas falsas y un culpable inesperado.</title><link>https://rootdevs.es/blog/youtube-not-playing-mac-hdmi-audio</link><guid isPermaLink="true">https://rootdevs.es/blog/youtube-not-playing-mac-hdmi-audio</guid><pubDate>Wed, 30 Sep 2026 00:00:00 GMT</pubDate><description>Cómo un hub USB-C dejó atascado el audio HDMI de mi Mac, y por qué YouTube se quedaba en el 0:00 mientras yo culpaba a la red durante horas.</description><category>macOS</category><category>Debugging</category><category>CoreAudio</category><category>YouTube</category><media:content medium="image" type="image/webp" url="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_ES_dark.webp"/><enclosure length="88602" type="image/webp" url="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_ES_dark.webp"/><content:encoded><![CDATA[<p><img alt="YouTube no reproduce en mi Mac: cinco pistas falsas y un culpable inesperado." src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_ES_dark.webp"></p><h2>🎬 Introducción: la página carga, el vídeo no</h2>
<p>Mi MacBook de empresa llevaba cuatro años funcionando perfecto con este montaje: un hub USB-C (Ethernet, webcam y salida VGA hacia una tele) y un monitor externo por HDMI cuyo audio uso, con el conector de auriculares del monitor alimentando los altavoces de la tele. Hasta que un día YouTube dejó de reproducir: la página cargaba perfecta, pero el vídeo se quedaba girando para siempre en el <strong>0:00</strong>.</p>
<p>Así se ve mi mesa de trabajo:</p>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/illustration_ES_dark.webp" alt="Ilustración de mi mesa: MacBook Pro con hub USB-C, monitor VS278 por HDMI con el audio atascado y una tele como segunda pantalla"><p>Lo más desconcertante era el patrón. Pasaba en casa, pero no en mi segunda vivienda, con otro ISP. Y un PC con Linux en la misma red doméstica, con el mismo navegador, funcionaba sin problemas. Todo apuntaba a la <strong>red o al proveedor de internet</strong>, combinados con un Mac gestionado por la empresa (MDM, un cliente SASE de <em>secure web gateway</em>, un perfil de DNS cifrado gestionado y un agente de seguridad de endpoint). Demasiados sospechosos.</p>
<blockquote>
<p>💡 Spoiler: la red era inocente. Pero tardé horas en aceptarlo, y el camino es lo que vale la pena contar.</p>
</blockquote>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/layers_ES_dark.webp" alt="Las capas que sospechamos y la que realmente fallaba"><hr>
<h2>🤖 Cómo lo trabajé: iterando con agentes</h2>
<p>Esta investigación no la hice solo. Trabajé en la terminal con agentes de IA (Claude Code) configurados con <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>, una herramienta de código abierto (<a href="https://gentle-ai.gentlemanprogramming.com/">web oficial</a>) que configura los agentes que ya usas con memoria persistente, Organic-Driven Development, skills y orquestación de sub-agentes, sin atarte a ninguno.</p>
<p><strong>Lo que aportó gentle-ai en este caso:</strong></p>
<ul>
<li><strong>Engram:</strong> memoria persistente entre sesiones, para no empezar de cero cada vez.</li>
<li><strong>ODD (Organic-Driven Development):</strong> un flujo ligero, con documento de tareas para el trabajo sustancial, rama antes de escribir y ningún commit sin mi permiso explícito.</li>
<li><strong>Orquestación de sub-agentes:</strong> el orquestador delegó en escritores que redactaron este post en tres idiomas y generaron los diagramas; yo revisé y corregí.</li>
</ul>
<p>gentle-ai lo creó <a href="https://www.linkedin.com/in/alanbuscaglia/">Alan Buscaglia</a> (Gentleman Programming), y su regla para trabajar con IA resume bien este post: <em>&quot;verifying beats generating&quot;</em>, es decir, verificar gana a generar.</p>
<blockquote>
<p>⭐ <strong>gentle-ai en GitHub:</strong> <a href="https://github.com/Gentleman-Programming/gentle-ai">github.com/Gentleman-Programming/gentle-ai</a>. Si te resulta útil, dale una estrella: ayuda a que más gente lo encuentre.</p>
</blockquote>
<p align="center"><a href="https://github.com/Gentleman-Programming/gentle-ai"><img width="220" src="https://raw.githubusercontent.com/Gentleman-Programming/gentle-ai/main/docs/assets/brand/built-with-gentle-ai.png" alt="Built with Gentle-AI" /></a></p><p>El método fue siempre el mismo, y nos equivocamos los dos: el agente también defendió teorías erróneas (la del cliente SASE y la caché del ISP fue suya) y la evidencia lo corrigió a él igual que a mí.</p>
<p><strong>hipótesis → prueba → evidencia → descartar</strong></p>
<ul>
<li>🤖 <strong>El agente:</strong> leyó logs de sistema y de red, inspeccionó configuraciones, capturó y decodificó el tráfico del reproductor, lanzó Chrome sin interfaz por DevTools, montó el monitor de reproducción, ejecutó las pruebas de aislamiento y escribió el vigilante en Swift con su LaunchAgent.</li>
<li>🙋 <strong>Yo:</strong> aporté contexto y capturas, hice las comprobaciones en el navegador que el agente no podía y, sobre todo, noté que fallaba justo al conectar el hub USB-C y confirmé que el montaje llevaba cuatro años funcionando igual.</li>
</ul>
<hr>
<h2>🕵️ Pista falsa #1: el filtro DNS de casa</h2>
<p>Lo primero fue lo más obvio: el Pi-hole de casa. Lo descartamos antes de empezar en serio, con evidencia: el DNS resolvía todo correctamente y solo bloqueaba dominios de publicidad. Nada que ver con los servidores de vídeo.</p>
<hr>
<h2>🚦 Pista falsa #2: QUIC / HTTP3</h2>
<p>El clásico de &quot;la página carga pero el vídeo no&quot;: un firewall o un proveedor que rompe QUIC. Desactivamos QUIC en el navegador (<code>brave://flags/#enable-quic</code>) y el vídeo <strong>siguió fallando</strong>. Descartado.</p>
<hr>
<h2>🧩 Pista falsa #3: bloqueadores y extensiones</h2>
<p>En la consola aparecían errores <code>net::ERR_BLOCKED_BY_CLIENT</code> para la telemetría de YouTube (<code>qoe</code>, <code>log_event</code>, <code>generate_204</code>). Muy sospechoso... hasta que probamos en una ventana privada con los <em>shields</em> desactivados: la telemetría pasaba y el vídeo seguía atascado. Aquellos errores eran <strong>ruido</strong>.</p>
<hr>
<h2>🛡️ Pista falsa #4: el cliente SASE</h2>
<p>Esta fue la más convincente. La extensión de red del cliente SASE seguía activa aunque la &quot;desactivara&quot; desde su interfaz (protección anti-manipulación), y sus logs, que leyó el agente, mostraban que gestionaba flujos del navegador hacia <code>googlevideo.com</code>. Además, mi proveedor servía el vídeo desde un nodo de caché de Google <strong>dentro de su propia red</strong>. La hipótesis, que fue del propio agente, encajaba de maravilla: el tráfico que sale por la nube del SASE es rechazado por la caché del ISP.</p>
<p>La desmontaron tres hechos:</p>
<ul>
<li>El certificado que recibía el navegador era el <strong>genuino de Google</strong>: no había interceptación TLS.</li>
<li>El nodo de caché respondía sin problema (un <code>204</code> correcto).</li>
<li>Y lo decisivo: <strong>Chrome ni siquiera pasa por ese cliente</strong> (cero flujos en su log) y fallaba exactamente igual.</li>
</ul>
<hr>
<h2>👻 Pista falsa #5: la &quot;ruta fantasma&quot; de IPv6</h2>
<p>Otro asistente de IA sugirió un problema de IPv6. Lo comprobó el agente, que después retractó la idea: el Mac no tenía IPv6 global, las rutas por defecto <code>utun</code> estaban ligadas a interfaz (son túneles del sistema), el intento IPv6 del navegador fallaba al instante y caía a IPv4, y mi red doméstica ni siquiera tiene IPv6. Descartado. Tampoco eran la cuenta ni las cookies: un perfil de Chrome totalmente limpio, sin cookies, sin cuenta y sin extensiones, también se quedaba colgado.</p>
<hr>
<h2>🔬 La pista que estaba a la vista</h2>
<p>Durante todo el proceso hubo una observación que en retrospectiva apuntaba lejos de la red. La respuesta de YouTube era <strong>válida</strong>: un stream SABR/UMP de aproximadamente 1 MB con audio y vídeo reales y el estado de protección de stream en OK. Los bytes llegaban rápido. Pero el reproductor <strong>nunca los consumía</strong>: <code>video.readyState</code> se quedaba en 0, <code>buffered</code> vacío y el reproductor en estado 3 (<em>buffering</em>).</p>
<blockquote>
<p><em>&quot;Los datos llegan, pero el reproductor no arranca.&quot;</em> Eso no es un problema de red.</p>
</blockquote>
<hr>
<h2>🔁 El giro: convertirlo en algo reproducible</h2>
<p>Tras reiniciar el Mac funcionó... y más tarde volvió a romperse. Así que dejé de adivinar y le pedí al agente que montara un <strong>monitor</strong>: cada 2 minutos lanzaba un Chrome limpio y sin interfaz a través del protocolo DevTools, cargaba un vídeo, comprobaba <code>readyState</code> y <code>currentTime</code>, y guardaba una instantánea del sistema en cada cambio de estado.</p>
<p>Entonces caí en la cuenta de algo, y esta parte fue mía, no del agente: <strong>se rompía justo cuando conectaba el hub USB-C</strong>, en un montaje que yo sabía que llevaba cuatro años funcionando igual. El agente hizo una prueba de aislamiento con el hub conectado:</p>
<table>
<thead>
<tr>
<th>Salida de audio</th>
<th>Chrome con <code>--disable-audio-output</code></th>
<th>Chrome con audio</th>
</tr>
</thead>
<tbody><tr>
<td>Altavoces del MacBook</td>
<td>OK</td>
<td>OK</td>
</tr>
<tr>
<td>Monitor HDMI (VS278)</td>
<td>OK</td>
<td>FALLA (atascado en 0:00)</td>
</tr>
</tbody></table>
<p>Ahí estaba. Sin audio, el vídeo reproduce; con audio hacia el monitor HDMI, no.</p>
<hr>
<h2>🎯 La causa raíz: un dispositivo de audio atascado</h2>
<p>Al conectar el hub, macOS reconfigura pantallas y dispositivos de audio, y el audio del monitor HDMI queda <strong>atascado</strong>, aunque nunca desaparece de la lista de dispositivos. Chromium no empieza la reproducción hasta que puede abrir la salida de audio, así que el vídeo espera para siempre aunque los datos ya hayan llegado.</p>
<p>Este es mi montaje, con el punto exacto donde falló:</p>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/setup_ES_dark.webp" alt="Mi montaje: dónde estaba el fallo"><p>Esto también explica el resto del misterio:</p>
<ul>
<li>📍 <strong>&quot;Solo pasa en casa&quot;</strong>: allí vive el montaje del hub y el monitor. La red era una pista falsa; la correlación de lugar era en realidad una correlación de <strong>hardware</strong>.</li>
<li>⏱️ <strong>&quot;Se rompe a ratos&quot;</strong>: el hub es algo tembloroso y se toca. Cada reconexión vuelve a disparar el problema.</li>
</ul>
<hr>
<h2>🔧 La solución manual</h2>
<p>Basta con <strong>reinicializar el dispositivo de audio</strong>. Tres formas:</p>
<ol>
<li>Abrir <em>Configuración Audio MIDI</em>, seleccionar el dispositivo HDMI y cambiar el formato de 48 000 Hz a 44 100 Hz y volver.</li>
<li>Cambiar la salida de sonido a otro dispositivo y devolverla.</li>
<li>Reiniciar el servicio de audio: <code>sudo killall coreaudiod</code>.</li>
</ol>
<p>Una contraprueba confirmó que lo que arregla es el <strong>reinicio</strong>, no la frecuencia: 48 kHz vuelve a funcionar después de un reset. Fijar una frecuencia concreta <strong>no</strong> ayuda, porque macOS ya la recuerda por dispositivo.</p>
<hr>
<h2>🤖 La solución automática</h2>
<p>Como el problema volvía con cada reconexión, le pedí al agente que lo automatizara y escribió un pequeño programa en Swift que usa CoreAudio y se ejecuta como LaunchAgent de usuario; lo revisé y lo instalé yo. Escucha los cambios en <code>kAudioHardwarePropertyDevices</code>, espera 4 segundos a que todo se asiente (<em>debounce</em>) y alterna la frecuencia nominal del dispositivo objetivo a otra disponible y de vuelta. La primera versión no se disparaba, porque el monitor nunca desaparece de la lista de dispositivos; el agente la corrigió para reaccionar a cualquier cambio en esa lista.</p>
<h3>🧾 El programa (<code>~/bin/hdmi-audio-reset.swift</code>)</h3>
<pre><code class="language-swift">// Watches CoreAudio devices and re-initializes a given output device whenever the device
// list changes (e.g. after plugging a USB-C hub), by toggling its nominal sample rate.
// Works around the device getting stuck, which blocks Chromium media playback.
// usage: hdmi-audio-reset &lt;deviceName&gt; [--now]
import CoreAudio
import Foundation

let targetName = CommandLine.arguments.count &gt; 1 ? CommandLine.arguments[1] : &quot;VS278&quot;
let settleSeconds: TimeInterval = 4

func log(_ message: String) {
  let line = &quot;\(ISO8601DateFormatter().string(from: Date())) \(message)\n&quot;
  FileHandle.standardError.write(line.data(using: .utf8)!)
}

func address(_ selector: AudioObjectPropertySelector) -&gt; AudioObjectPropertyAddress {
  AudioObjectPropertyAddress(mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
}

func deviceIDs() -&gt; [AudioDeviceID] {
  var addr = address(kAudioHardwarePropertyDevices)
  var size: UInt32 = 0
  guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &amp;addr, 0, nil, &amp;size) == noErr else { return [] }
  var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout&lt;AudioDeviceID&gt;.size)
  guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &amp;addr, 0, nil, &amp;size, &amp;ids) == noErr else { return [] }
  return ids
}

func name(of id: AudioDeviceID) -&gt; String? {
  var addr = address(kAudioObjectPropertyName)
  var value: Unmanaged&lt;CFString&gt;?
  var size = UInt32(MemoryLayout&lt;Unmanaged&lt;CFString&gt;?&gt;.size)
  guard AudioObjectGetPropertyData(id, &amp;addr, 0, nil, &amp;size, &amp;value) == noErr else { return nil }
  return value?.takeRetainedValue() as String?
}

func findTarget() -&gt; AudioDeviceID? { deviceIDs().first { name(of: $0) == targetName } }

func sampleRate(of id: AudioDeviceID) -&gt; Float64? {
  var addr = address(kAudioDevicePropertyNominalSampleRate)
  var rate: Float64 = 0
  var size = UInt32(MemoryLayout&lt;Float64&gt;.size)
  return AudioObjectGetPropertyData(id, &amp;addr, 0, nil, &amp;size, &amp;rate) == noErr ? rate : nil
}

func availableRates(of id: AudioDeviceID) -&gt; [Float64] {
  var addr = address(kAudioDevicePropertyAvailableNominalSampleRates)
  var size: UInt32 = 0
  guard AudioObjectGetPropertyDataSize(id, &amp;addr, 0, nil, &amp;size) == noErr else { return [] }
  var ranges = [AudioValueRange](repeating: AudioValueRange(), count: Int(size) / MemoryLayout&lt;AudioValueRange&gt;.size)
  guard AudioObjectGetPropertyData(id, &amp;addr, 0, nil, &amp;size, &amp;ranges) == noErr else { return [] }
  return ranges.map { $0.mMinimum }
}

@discardableResult
func setSampleRate(_ id: AudioDeviceID, _ rate: Float64) -&gt; OSStatus {
  var addr = address(kAudioDevicePropertyNominalSampleRate)
  var value = rate
  return AudioObjectSetPropertyData(id, &amp;addr, 0, nil, UInt32(MemoryLayout&lt;Float64&gt;.size), &amp;value)
}

func resetTarget() {
  guard let id = findTarget(), let current = sampleRate(of: id) else { log(&quot;\(targetName) not found; skipping&quot;); return }
  guard let other = availableRates(of: id).first(where: { $0 != current }) else { log(&quot;no alternate rate for \(targetName)&quot;); return }
  let first = setSampleRate(id, other)
  Thread.sleep(forTimeInterval: 1)
  let second = setSampleRate(id, current)
  log(&quot;reset \(targetName): \(Int(current)) -&gt; \(Int(other)) -&gt; \(Int(current)) (status \(first)/\(second))&quot;)
}

let wasPresent = findTarget() != nil
if CommandLine.arguments.contains(&quot;--now&quot;) { resetTarget(); exit(0) }
log(&quot;watching for \(targetName) (present=\(wasPresent))&quot;)
if wasPresent { DispatchQueue.main.asyncAfter(deadline: .now() + settleSeconds) { resetTarget() } }

// Any device-list change (hub plugged/unplugged, displays reconfigured) can leave the
// target stuck even though it never disappears, so reset it after changes settle.
var pendingReset: DispatchWorkItem?
var devicesAddr = address(kAudioHardwarePropertyDevices)
AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &amp;devicesAddr, DispatchQueue.main) { _, _ in
  log(&quot;audio devices changed; resetting \(targetName) in \(Int(settleSeconds))s&quot;)
  pendingReset?.cancel()
  let work = DispatchWorkItem { resetTarget() }
  pendingReset = work
  DispatchQueue.main.asyncAfter(deadline: .now() + settleSeconds, execute: work)
}
dispatchMain()
</code></pre>
<h3>📄 El LaunchAgent (<code>~/Library/LaunchAgents/local.hdmi-audio-reset.plist</code>)</h3>
<p>Sustituye <code>YOUR_USER</code> por tu usuario y el nombre <code>VS278</code> por el de tu dispositivo de audio.</p>
<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;!DOCTYPE plist PUBLIC &quot;-//Apple//DTD PLIST 1.0//EN&quot; &quot;http://www.apple.com/DTDs/PropertyList-1.0.dtd&quot;&gt;
&lt;plist version=&quot;1.0&quot;&gt;
&lt;dict&gt;
  &lt;key&gt;Label&lt;/key&gt;&lt;string&gt;local.hdmi-audio-reset&lt;/string&gt;
  &lt;key&gt;ProgramArguments&lt;/key&gt;
  &lt;array&gt;&lt;string&gt;/Users/YOUR_USER/bin/hdmi-audio-reset&lt;/string&gt;&lt;string&gt;VS278&lt;/string&gt;&lt;/array&gt;
  &lt;key&gt;RunAtLoad&lt;/key&gt;&lt;true/&gt;
  &lt;key&gt;KeepAlive&lt;/key&gt;&lt;true/&gt;
  &lt;key&gt;StandardErrorPath&lt;/key&gt;&lt;string&gt;/Users/YOUR_USER/Library/Logs/hdmi-audio-reset.log&lt;/string&gt;
&lt;/dict&gt;
&lt;/plist&gt;
</code></pre>
<h3>▶️ Compilar e instalar</h3>
<pre><code class="language-bash">swiftc -O -o ~/bin/hdmi-audio-reset ~/bin/hdmi-audio-reset.swift
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.hdmi-audio-reset.plist
</code></pre>
<p>Para desinstalarlo:</p>
<pre><code class="language-bash">launchctl bootout gui/$(id -u)/local.hdmi-audio-reset
</code></pre>
<p>Lo verifiqué de punta a punta: desconecté y volví a conectar el hub, el log mostró <code>audio devices changed; resetting …</code>, después el reinicio, y YouTube reprodujo sin tocar nada.</p>
<hr>
<h2>🧠 Lecciones</h2>
<ul>
<li><strong>Descarta capas con evidencia, no con intuición.</strong> Cada pista falsa se cerró con una prueba concreta, no con una corazonada.</li>
<li><strong>Desconfía de la primera historia plausible.</strong> El agente y yo perseguimos la red durante horas porque la historia encajaba demasiado bien (la teoría del SASE y la caché del ISP fue del propio agente).</li>
<li><strong>&quot;Los datos llegan pero nada suena&quot; significa mirar el pipeline multimedia</strong> (audio, decodificador), no la red.</li>
<li><strong>Haz el fallo reproducible y automático.</strong> Un monitor cada 2 minutos me dio lo que la intuición no pudo: el desencadenante.</li>
<li><strong>Cambia una variable a la vez.</strong> La prueba de aislamiento con la tabla de arriba resolvió en minutos lo que horas de hipótesis no.</li>
<li><strong>La correlación de lugar era una correlación de montaje de hardware.</strong></li>
</ul>
<p>La investigación la hice iterando con agentes de IA en la terminal (Claude Code, configurado con <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>): ejecutaron y ordenaron las pruebas, y cayeron en teorías equivocadas igual que yo. Este post también lo redactaron sub-agentes orquestados por gentle-ai (el texto en tres idiomas y las ilustraciones), y lo revisé y corregí yo. Lo que nos hizo avanzar a los dos fue descartar con evidencia.</p>
<p>Si tienes un Mac, un hub USB-C y audio por HDMI, y YouTube se queda en el 0:00, prueba primero a reiniciar el dispositivo de audio. Te puede ahorrar una tarde entera. 🎧</p>
]]></content:encoded></item>
<item><title>Código Impecable en Equipos Multi-Consultora.</title><link>https://rootdevs.es/blog/spotless-code-multi-vendor-teams</link><guid isPermaLink="true">https://rootdevs.es/blog/spotless-code-multi-vendor-teams</guid><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><description>Cómo Prettier, Husky y lint-staged eliminan las guerras de estilo en repositorios con múltiples consultoras, sin sacrificar el historial de Git.</description><category>Prettier</category><category>Git</category><category>DevEx</category><category>Husky</category><media:content medium="image" type="image/webp" url="https://rootdevs.es/assets/images/blog/prettierPost/infographic_ES.webp"/><enclosure length="265428" type="image/webp" url="https://rootdevs.es/assets/images/blog/prettierPost/infographic_ES.webp"/><content:encoded><![CDATA[<p><img alt="Código Impecable en Equipos Multi-Consultora." src="https://rootdevs.es/assets/images/blog/prettierPost/infographic_ES.webp"></p><h2>🌪️ Introducción: El Caos Invisible de los Espacios y las Comas</h2>
<p>En proyectos de gran envergadura donde coexisten múltiples consultoras, la diversidad de criterios no es solo una riqueza cultural, sino también un riesgo técnico. Surge un problema tan silencioso como disruptivo: <strong>la inconsistencia visual</strong>. Cuando cada equipo aporta su propio ADN de desarrollo —unos prefieren tabulaciones, otros espacios; comillas simples frente a dobles— el repositorio deja de ser un activo cohesionado para convertirse en un campo de batalla de cambios irrelevantes.</p>
<p>Esta falta de unificación no es un simple detalle estético; es una fuente de fricción operativa y <em>vendor finger-pointing</em> —señalamiento entre proveedores— durante las revisiones de código. Las discusiones sobre la posición de una llave o la longitud de una línea <strong>roban minutos de oro</strong> que deberían invertirse en la arquitectura o la lógica de negocio.</p>
<blockquote>
<p>💡 Para un líder técnico, estandarizar no es un capricho: es una estrategia para eliminar el ruido y garantizar que los Code Reviews se centren en lo que realmente aporta valor.</p>
</blockquote>
<hr>
<h2>✨ El Fin de las Guerras de Estilo con Prettier</h2>
<p>La solución para erradicar estas fricciones es delegar la responsabilidad en <strong>Prettier</strong>. Como formateador de código <em>opinativo</em>, Prettier elimina la subjetividad al parsear el código y reimprimirlo bajo reglas estrictas y predefinidas.</p>
<p>Lo que lo hace indispensable para un Arquitecto de Software es su <strong>seguridad</strong>: Prettier actúa sobre la capa de presentación sin alterar el Árbol de Sintaxis Abstracta (AST). Esto ofrece una garantía fundamental:</p>
<blockquote>
<p><em>&quot;El funcionamiento de la aplicación se mantiene exactamente igual&quot;</em></p>
</blockquote>
<p>Adoptar Prettier es, en esencia, un acto de liberación para el equipo. Al automatizar el estilo, el desarrollador se desprende de la carga cognitiva de &quot;encajar&quot; visualmente con el resto, permitiendo que la <strong>Developer Experience (DevEx)</strong> fluya hacia la resolución de problemas complejos.</p>
<h3>⚙️ Configuración mínima en <code>.prettierrc</code></h3>
<pre><code class="language-json">{
  &quot;arrowParens&quot;: &quot;always&quot;,
  &quot;bracketSameLine&quot;: false,
  &quot;printWidth&quot;: 100,
  &quot;semi&quot;: true,
  &quot;singleQuote&quot;: true,
  &quot;tabWidth&quot;: 2,
  &quot;trailingComma&quot;: &quot;es5&quot;
}
</code></pre>
<hr>
<h2>🕵️ El &quot;Hack&quot; de Git para Formatear sin Perder el Rastro</h2>
<p>El mayor temor al implementar un estándar en un proyecto vivo es el <strong>commit masivo</strong>. Al formatear toda la base de código de una vez, corremos el riesgo de oscurecer la autoría original. Si un desarrollador usa <code>git blame</code> para entender por qué se tomó una decisión técnica hace dos años, se encontrará con que el autor es <em>&quot;el bot de formateo&quot;</em>, perdiendo la trazabilidad histórica.</p>
<p>Para evolucionar la base de código sin sacrificar este rastro, utilizamos el archivo <code>.git-blame-ignore-revs</code>.</p>
<h3>🔍 Cómo funciona</h3>
<ol>
<li>Formateáis el proyecto completo y hacéis commit.</li>
<li>Obtenéis el hash del commit de formateo.</li>
<li>Lo registráis en <code>.git-blame-ignore-revs</code>:</li>
</ol>
<pre><code class="language-bash"># Commit de formateo masivo con Prettier — 2024-10-15
a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
</code></pre>
<ol start="4">
<li>Configuráis Git para que lo use automáticamente:</li>
</ol>
<pre><code class="language-bash">git config blame.ignoreRevsFile .git-blame-ignore-revs
</code></pre>
<p>Esta funcionalidad permite:</p>
<ul>
<li>👤 <strong>Preservar la autoría original</strong>: Git ignora ese commit en los rastreos.</li>
<li>🏛️ <strong>Integridad del historial</strong>: GitHub y GitLab respetan este archivo, mostrando al verdadero autor de la lógica.</li>
<li>🔇 <strong>Evolución sin ruido</strong>: Alcanzad la perfección visual sin <em>&quot;ensuciar&quot;</em> la responsabilidad histórica de cada línea.</li>
</ul>
<hr>
<h2>🌐 Node.js como la &quot;Fuente de la Verdad&quot; Multiplataforma</h2>
<p>En ecosistemas modernos, especialmente en proyectos basados en Angular, la independencia del sistema operativo es crítica. El error clásico de muchas automatizaciones es depender de scripts en <strong>Bash</strong>, que suelen fallar en entornos Windows o requieren configuraciones manuales tediosas.</p>
<p>Nuestra arquitectura propone un cambio de paradigma: <strong>tratar el repositorio como un entorno de aplicación en lugar de un entorno de sistema</strong>. Al centralizar todo en Node.js, garantizamos un comportamiento <em>cross-platform</em> real:</p>
<ul>
<li>💻 <strong>Agnosticismo de hardware</strong>: El flujo funciona igual en una MacBook M3 que en una estación Windows 10.</li>
<li>🔒 <strong>Entorno controlado</strong>: Si el desarrollador tiene instalado Node.js (requisito para Angular), la automatización funcionará por defecto.</li>
<li>📦 <strong>Unificación de herramientas</strong>: El <code>package.json</code> se convierte en la única fuente de la verdad.</li>
</ul>
<hr>
<h2>🐶 Husky y lint-staged: Los Guardianes Silenciosos del Repositorio</h2>
<p>Para que la estandarización sea efectiva, debe ser <strong>invisible y obligatoria</strong>. Implementamos un flujo de cumplimiento forzado mediante <strong>Husky</strong> y <strong>lint-staged</strong>, actuando como un filtro de calidad antes de que el código llegue al servidor:</p>
<ol>
<li>🚀 <strong>Activación</strong>: El desarrollador ejecuta <code>git commit</code>.</li>
<li>🪝 <strong>Intercepción</strong>: Husky captura el evento y delega la ejecución a Node.js.</li>
<li>🎯 <strong>Filtrado Inteligente</strong>: lint-staged identifica únicamente los archivos en el <em>Staging Area</em>. No perdemos tiempo procesando miles de archivos; solo lo que ha cambiado.</li>
<li>✅ <strong>Formateo y Validación</strong>: Prettier aplica el formato. Si el código tiene errores de sintaxis graves, el commit se aborta.</li>
</ol>
<h3>📦 Instalación</h3>
<pre><code class="language-bash">npm install --save-dev husky lint-staged
npx husky init
</code></pre>
<h3>🔧 Configuración en <code>package.json</code></h3>
<pre><code class="language-json">{
  &quot;lint-staged&quot;: {
    &quot;*.{ts,html,scss,json,md}&quot;: [&quot;prettier --write&quot;]
  }
}
</code></pre>
<h3>Hook pre-commit (<code>.husky/pre-commit</code>)</h3>
<pre><code class="language-bash">node_modules/.bin/lint-staged
</code></pre>
<blockquote>
<p>🛡️ Este proceso elimina el <strong>factor del olvido humano</strong> y garantiza que ningún código fuera de estándar entre jamás al repositorio, sin que el desarrollador tenga que ejecutar un solo comando adicional.</p>
</blockquote>
<hr>
<h2>📋 Plan de Acción: Prueba de Concepto (POC)</h2>
<p>Como Arquitectos, no implementamos cambios críticos a ciegas. El camino hacia la excelencia visual sigue una hoja de ruta técnica clara:</p>
<ol>
<li>🌿 <strong>Aislamiento</strong>: Crear una rama <code>feature/estandarizacion-prettier</code> para no interrumpir el flujo actual.</li>
<li>⚙️ <strong>Configuración</strong>: Instalar el stack (Prettier, Husky, lint-staged) y definir las reglas en <code>.prettierrc</code>.</li>
<li>💥 <strong>Ejecución Masiva</strong>: Formatear el proyecto completo y generar el commit de referencia.</li>
<li>🛡️ <strong>Blindaje del Historial</strong>: Configurar el archivo <code>.git-blame-ignore-revs</code> con el hash obtenido.</li>
<li>🎬 <strong>Demo en Vivo</strong>: Mostrar al equipo y al cliente cómo un código mal indentado en Windows se corrige instantáneamente al hacer commit, manteniendo la autoría original en el historial.</li>
</ol>
<hr>
<h2>🚀 Conclusión: Hacia una Cultura de Código Limpio</h2>
<p>La adopción de este ecosistema no es solo una mejora técnica; es una <strong>declaración de principios</strong> sobre la calidad. Los beneficios son inmediatos: revisiones de código más ágiles, un historial de Git impecable y la eliminación total de la fricción estética entre consultoras.</p>
<p>La inversión para implementar esta arquitectura es mínima frente al ahorro de tiempo y frustración a largo plazo. Al final del día, la pregunta para cualquier líder tecnológico es simple:</p>
<blockquote>
<p>¿Cuánto tiempo está perdiendo su equipo discutiendo sobre el formato de las comas, cuando podrían estar fortaleciendo la arquitectura de su sistema?</p>
</blockquote>
<p>La automatización es el puente entre el caos visual y la excelencia técnica. ¡Y feliz 4 de Mayo, que la fuerza os acompañe en cada <em>commit</em>! 🌌⚔️</p>
]]></content:encoded></item>
</channel>
</rss>
