<?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>Artigos técnicos de Francisco Javier Antúnez Durán sobre Angular, desempenho, ferramentas e IA.</description>
<language>pt</language>
<lastBuildDate>Tue, 27 Oct 2026 00:00:00 GMT</lastBuildDate>
<atom:link href="https://rootdevs.es/rss/pt.xml" rel="self" type="application/rss+xml"/>
<item><title>Como construí o Framed by Santi: do zero à produção em três dias.</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>Um portfólio de fotografia com painel privado, três idiomas e 100 no Lighthouse, construído com o Claude Code como assistente: o stack, as funcionalidades, os problemas reais e o método para avançar rápido sem perder qualidade.</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_PT_dark.webp"/><enclosure length="74528" type="image/webp" url="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_PT_dark.webp"/><content:encoded><![CDATA[<p><img alt="Como construí o Framed by Santi: do zero à produção em três dias." src="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_PT_dark.webp"></p><h2>📸 Introdução: um portfólio de fotografia em produção ao terceiro dia</h2>
<p>O <a href="https://framedbysanti.rootdevs.es">Framed by Santi</a> é o site de <a href="https://www.instagram.com/framedbysanti">@framedbysanti</a>, um fotógrafo amador que fotografa as ruas de Nova Iorque com uma Fujifilm X100VI. Tem duas faces: um site público para mostrar as fotos dele e um painel privado para que ele próprio publique sem depender de ninguém.</p>
<p>Começou como um presente para um amigo, mas este artigo não é sobre isso: é sobre <strong>como o construí em muito pouco tempo, com a ajuda do Claude Code, sem abdicar da qualidade</strong>.</p>
<p>Os números, tirados do repositório:</p>
<ul>
<li>📅 <strong>9 dias com commits</strong>, de 13 a 26 de outubro de 2026.</li>
<li>🚀 <strong>Em produção ao dia 3</strong>: site público e painel privado.</li>
<li>🔀 <strong>189 commits</strong> e <strong>35 pull requests</strong>; cada alteração foi testada por mim, como visitante e como admin, antes de chegar a produção.</li>
<li>🧪 <strong>117 ficheiros de teste</strong> com <strong>857 casos</strong>.</li>
<li>🌍 <strong>Três idiomas</strong>: inglês, espanhol e português.</li>
<li>💯 <strong>100 nas quatro categorias do Lighthouse</strong> (desktop, rede 4G).</li>
</ul>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/framedBySanti/commits_PT_dark.webp" alt="Gráfico de barras de commits por dia de 13 a 26 de outubro de 2026: 1, 43, 75 (o dia da entrada em produção), 3, nenhum no fim de semana, 22, 7, 23, 3, nenhum de 23 a 25 e 12"><hr>
<h2>🤖 Como trabalhei: agentes de IA, mas com método</h2>
<p>Construí-o no terminal com agentes de IA (Claude Code) configurados com o <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>, a ferramenta de código aberto de <a href="https://www.linkedin.com/in/alanbuscaglia/">Alan Buscaglia</a> (Gentleman Programming). Não é &quot;pedir um site à IA&quot;: é um fluxo de trabalho com regras.</p>
<ul>
<li><strong>ODD (Organic-Driven Development):</strong> as alterações pequenas fazem-se diretamente; o trabalho grande tem um documento de tarefas com os requisitos copiados literalmente, para que nada se perca entre sessões.</li>
<li><strong>Engram:</strong> memória persistente. Cada decisão, erro resolvido ou preferência fica guardada e o agente recupera-a na sessão seguinte. Por exemplo: &quot;pergunta antes de fazer push, mesmo para staging&quot;.</li>
<li><strong>Testes primeiro:</strong> para cada regra nova, primeiro um teste que falha e depois o código que o faz passar.</li>
<li><strong>Ramos e revisão:</strong> cada alteração sai de um ramo, sobe para <code>develop</code>, testo-a em <code>staging</code> e só chega a <code>main</code> (produção) quando eu a aprovo.</li>
</ul>
<p>A divisão foi clara:</p>
<ul>
<li>🤖 <strong>O agente:</strong> escreveu o código e os testes, investigou os erros, propôs soluções e abriu os PR.</li>
<li>🙋 <strong>Eu:</strong> decidi o que construir e como tinha de funcionar, revi cada alteração, corrigi o agente quando se enganava e aprovei cada entrada em produção.</li>
</ul>
<blockquote>
<p>💡 A IA escreve depressa; o que torna o resultado bom é <strong>verificar</strong>. Cada PR passou lint, testes e build, e nenhuma alteração chegou a produção sem a minha aprovação.</p>
</blockquote>
<hr>
<h2>🧱 Stack e arquitetura</h2>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/framedBySanti/architecture_PT_dark.webp" alt="Diagrama de arquitetura do Framed by Santi: o navegador recebe a app estática da Vercel, lê o Firestore por REST nas páginas públicas e usa o Auth e o SDK do Firebase só no painel; as fotos chegam pela CDN da Cloudinary e o painel envia-as diretamente com uma assinatura; as funções da Vercel limpam a Cloudinary, enviam o email com o Resend, traduzem com o DeepL e assinam os uploads, e todas as do painel verificam no Firestore que o utilizador é admin com o seu próprio token"><p><strong>Frontend</strong></p>
<ul>
<li><strong>Angular 22</strong> sem Zone.js (<em>zoneless</em>), com <strong>Signals</strong> para todo o estado e componentes <em>standalone</em>.</li>
<li><strong>TypeScript 6</strong> em modo estrito: sem <code>any</code> e com tipos de retorno explícitos.</li>
<li><strong>CSS nativo</strong> com <em>nesting</em>, BEM e <em>mobile-first</em>. Sem pré-processador.</li>
<li><strong>Vite 8</strong> com <code>@analogjs/vite-plugin-angular</code> como única ferramenta de build.</li>
<li><strong>Transloco</strong> para os três idiomas.</li>
</ul>
<p><strong>Serviços</strong></p>
<ul>
<li><strong>Vercel:</strong> alojamento e quatro funções <em>serverless</em>.</li>
<li><strong>Firebase Auth e Firestore:</strong> login do painel e dados (séries, temas, definições).</li>
<li><strong>Cloudinary:</strong> armazenamento e entrega de fotos, com uploads assinados.</li>
<li><strong>DeepL:</strong> tradução automática dos textos.</li>
<li><strong>Resend:</strong> o email do formulário de contacto.</li>
</ul>
<p><strong>As quatro funções de <code>api/</code></strong></p>
<table>
<thead>
<tr>
<th>Função</th>
<th>O que faz</th>
</tr>
</thead>
<tbody><tr>
<td><code>sign-upload</code></td>
<td>Verifica que quem chama é admin e assina o upload para a Cloudinary.</td>
</tr>
<tr>
<td><code>translate</code></td>
<td>Proxy para o DeepL, só para admins, com 10 segundos de limite.</td>
</tr>
<tr>
<td><code>cleanup-photos</code></td>
<td>Apaga da Cloudinary as fotos que já nada usa.</td>
</tr>
<tr>
<td><code>contact</code></td>
<td>Valida o formulário e envia o email com o Resend.</td>
</tr>
</tbody></table>
<p>As três do painel verificam que o utilizador é admin da mesma forma: leem o documento dele no Firestore <strong>com o token do próprio utilizador</strong>, por isso são as regras de segurança do Firestore que decidem, não um segredo no servidor.</p>
<hr>
<h2>🖼️ O site público</h2>
<ul>
<li><strong>As publicações são séries</strong>, como um carrossel do Instagram: várias fotos com um título e uma história.</li>
<li><strong>Início</strong> com a última série num carrossel que se pode arrastar.</li>
<li><strong>Galeria</strong> com uma secção por série e filtro por temas.</li>
<li><strong>Visor em ecrã inteiro:</strong> zoom e deslocação (beliscar, arrastar, toque duplo, roda), gestos para mudar de foto, miniaturas, pré-carregamento da foto seguinte e uma transição suave entre fotos.</li>
<li><strong>View Transitions</strong> entre páginas, com um efeito de íris como o diafragma de uma câmara.</li>
<li><strong>Sobre</strong> com a história do Santi nas palavras dele e um formulário de contacto. O email dele nunca chega à página.</li>
<li><strong>Idiomas a sério:</strong> datas, números e plurais seguem o idioma ativo (<code>en-US</code>, <code>es-ES</code>, <code>pt-PT</code>).</li>
<li>Com <code>prefers-reduced-motion</code>, nada é animado.</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_PT.webp" alt="Página inicial do Framed by Santi: foto do Flatiron em ecrã inteiro com o nome do Santi e os botões para a galeria e para o Instagram" width="720"></a><br><em>A página inicial. Clica para a veres ao 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_PT.webp" alt="Galeria do Framed by Santi: filtro por temas e uma série com o seu carrossel de fotos" width="720"></a><br><em>A galeria com o 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_PT.webp" alt="Visor do Framed by Santi: uma foto em grande com setas, controlos de zoom e a fita de miniaturas" width="720"></a><br><em>O visor: zoom, setas e miniaturas.</em></p><hr>
<h2>🔐 O painel privado</h2>
<p>O painel vive em <code>/admin</code> e não tem nenhuma ligação pública: só se chega lá escrevendo o caminho.</p>
<ul>
<li><strong>Biblioteca de séries:</strong> interruptor de publicada ou rascunho, editar e apagar.</li>
<li><strong>Editor de séries:</strong> arrastar fotos, pré-visualizá-las, reordená-las e escrever os textos num separador por idioma.</li>
<li><strong>Fotos preparadas no navegador:</strong> antes do upload, cada foto é redimensionada e recomprimida até um máximo de 6000 px e 10 MB, baixando a qualidade do JPEG por passos até caber. Assim respeitam-se os limites do plano gratuito da Cloudinary sem que o Santi tenha de pensar nisso.</li>
<li><strong>Tradução automática:</strong> o Santi escreve num idioma e, ao publicar, o DeepL preenche os que faltam. Cada idioma pode ser corrigido à mão.</li>
<li><strong>Temas:</strong> criam-se e apagam-se a partir do painel.</li>
<li><strong>Limpeza automática:</strong> ao abrir a biblioteca, as fotos da Cloudinary que já nenhuma série usa são apagadas em segundo plano.</li>
<li><strong>Definições:</strong> foto de perfil e alteração da palavra-passe.</li>
<li><strong>Também no telemóvel:</strong> o painel adapta-se ao ecrã do telemóvel, e no login pode-se mostrar ou ocultar a palavra-passe.</li>
<li><strong>E um <em>easter egg</em></strong> só para o admin: um cantinho para continuar com força. 💪</li>
</ul>
<p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/dashboard-library_PT.webp" alt="Biblioteca do painel privado: as séries com a sua capa, o filtro por publicadas e rascunhos, e o interruptor para publicar" width="720"><br><em>A biblioteca: cada série é publicada ou passa a rascunho com um interruptor.</em></p><p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/dashboard-editor_PT.webp" alt="Editor de séries do painel privado: as fotos da série à esquerda e, à direita, os textos com um separador por idioma e o botão Traduzir" width="720"><br><em>O editor: fotos à esquerda, textos por idioma e o botão para traduzir à direita.</em></p><hr>
<h2>⚡ Desempenho: medir, cortar e voltar a medir</h2>
<p>Não deixei o desempenho para o fim: medi com o Lighthouse, ataquei o que mais pesava e voltei a medir. É assim que está hoje em desktop com rede 4G:</p>
<p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/lighthouse.webp" alt="Relatório do Lighthouse do Framed by Santi em desktop com rede 4G: 100 em Performance, Accessibility, Best Practices e SEO" width="420"></p><h3>Primeira ronda: de 44 a 73 em telemóvel</h3>
<p>A primeira medição, a 15 de outubro em telemóvel com 4G lento (o caso mais exigente), deu um desempenho de <strong>44-48</strong>, com um CLS de <strong>0,333</strong> e um LCP de <strong>7,5 s</strong>. Com três alterações resolvi quase tudo:</p>
<ul>
<li><strong>Pré-carregar a foto principal da página inicial</strong> a partir do <code>index.html</code>, com <code>fetchpriority=&quot;high&quot;</code>. O script de pré-carregamento vem antes de qualquer folha de estilos, porque uma folha pendente bloqueia os scripts que vêm a seguir.</li>
<li><strong>A página inicial vai no pacote principal</strong>, sem carregamento diferido, para não acrescentar uma viagem de rede antes de pintar a primeira página.</li>
<li><strong>O rodapé é pintado depois da primeira navegação.</strong> Era o responsável por todo o CLS. O carrossel da última série também reserva o seu espaço enquanto carrega.</li>
</ul>
<p>Resultado em telemóvel: <strong>desempenho 73, CLS 0,001 e LCP 5,6 s</strong>.</p>
<h3>Segunda ronda: tirar o que não fazia falta</h3>
<ul>
<li><strong>Tipos de letra próprios.</strong> Sirvo-os a partir do próprio domínio, sem a ida e volta ao Google Fonts.</li>
<li><strong>Firebase fora das páginas públicas.</strong> Ao rever o build vi que o pacote do Firebase pesava <strong>563 kB (168 kB comprimidos)</strong> e era pré-carregado em todas as páginas, e que cada visita abria ainda um iframe de autenticação da Google com cerca de <strong>93 KiB</strong>. Tudo isso para ler séries que são públicas. Agora as páginas públicas leem o Firestore com <code>fetch</code> contra a sua API REST, com as mesmas regras de segurança.</li>
<li><strong>O login só é carregado quando faz falta:</strong> uma marca local indica que nesse navegador já entrou um admin. Os visitantes nunca descarregam o Firebase Auth. E a marca não dá nenhuma permissão: a verificação real continua a ser o Firebase.</li>
</ul>
<p>Resultado em desktop com rede 4G: <strong>100 em Performance, Accessibility, Best Practices e SEO</strong>.</p>
<h3>O que já vem de série</h3>
<ul>
<li><strong>Imagens responsivas:</strong> cada foto é servida pela Cloudinary com <code>srcset</code> em cinco larguras (480, 800, 1200, 1600 e 2400 px) e formato e qualidade automáticos.</li>
<li><strong>Cada página carrega quando é visitada</strong> (<em>lazy loading</em>), exceto a página inicial.</li>
</ul>
<blockquote>
<p>💡 A lição: <strong>medir antes de otimizar</strong>. O maior problema de CLS era um rodapé, e o maior peso, uma biblioteca que os visitantes nem sequer usavam.</p>
</blockquote>
<hr>
<h2>🛡️ Segurança</h2>
<ul>
<li><strong>Regras do Firestore que negam tudo por defeito.</strong> O público só pode ler as séries publicadas, e as definições só aceitam os campos permitidos.</li>
<li><strong>Uploads assinados:</strong> o navegador nunca tem permissão para enviar para a Cloudinary por conta própria; cada upload é assinado por uma função que primeiro verifica que és admin.</li>
<li><strong>Formulário de contacto</strong> com limites de comprimento, HTML escapado e um campo armadilha: os bots preenchem-no, recebem um &quot;OK&quot; e nada é enviado.</li>
<li><strong>Staging não é indexado:</strong> os builds de staging levam <code>noindex</code> e um <code>robots.txt</code> que proíbe tudo.</li>
</ul>
<hr>
<h2>🧪 Qualidade: a ordem não se negoceia, o linter impõe-na</h2>
<p>Quando a IA escreve a maior parte do código, as convenções não podem depender de alguém se lembrar delas. Por isso transformei-as em regras: se o código não segue a ordem, <strong>o lint falha</strong>, seja quem for que o escreveu. As regras existem desde o primeiro dia do projeto.</p>
<p><strong>Cinco regras de ESLint próprias</strong>, escritas para este projeto:</p>
<table>
<thead>
<tr>
<th>Regra</th>
<th>O que obriga</th>
<th>Corrige-se sozinha?</th>
</tr>
</thead>
<tbody><tr>
<td><code>component-decorator-order</code></td>
<td>As propriedades de <code>@Component</code> sempre na mesma ordem: <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>As chamadas a <code>inject()</code> vêm antes de qualquer outra propriedade da classe.</td>
<td>✅</td>
</tr>
<tr>
<td><code>alphabetical-component-imports</code></td>
<td>O array <code>imports</code> do componente, por ordem alfabética.</td>
<td>✅</td>
</tr>
<tr>
<td><code>alphabetical-inputs-signals</code></td>
<td>Os <code>input()</code> e os <code>signal()</code>, por ordem alfabética dentro do seu grupo.</td>
<td>❌</td>
</tr>
<tr>
<td><code>alphabetical-interface-members</code></td>
<td>Os membros de interfaces e tipos, por ordem alfabética.</td>
<td>❌</td>
</tr>
</tbody></table>
<p>As duas últimas só avisam, não reordenam sozinhas: nas interfaces, por exemplo, mover membros pode partir código que usa <em>spread</em> ou <code>Object.assign</code>, por isso prefiro que seja uma pessoa a decidir.</p>
<p><strong>E o resto da ordem, também automático:</strong></p>
<ul>
<li><strong>Imports</strong> agrupados e ordenados: primeiro o Angular e as bibliotecas externas, depois os aliases internos (<code>@core</code>, <code>@features</code>...) e no fim os relativos.</li>
<li><strong>Chaves de objetos</strong> por ordem alfabética em dados, constantes, modelos e interfaces.</li>
<li><strong>JSON de tradução</strong> com as chaves ordenadas, sem comentários.</li>
<li><strong>Prettier</strong> com plugins que ordenam as propriedades CSS, os atributos HTML e as chaves dos JSON.</li>
<li><strong>TypeScript estrito:</strong> <code>any</code> é um erro, os tipos de retorno são obrigatórios e usa-se sempre <code>===</code>.</li>
</ul>
<h3>O padrão dicionário</h3>
<p>Há uma prática que peço sempre e que nenhum linter impõe: o <strong>padrão dicionário</strong>, conhecido em inglês como <em>object lookup</em> (e como <em>dispatch table</em> quando os valores são funções). Quando uma função traduz um valor noutro, nada de <code>switch</code> nem de <code>if/else</code> encadeados: um objeto tipado com <code>Record</code>, acesso por chave e um valor por defeito com <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>Três vantagens:</p>
<ul>
<li><strong>Lê-se de relance:</strong> todos os casos numa tabela, em vez de espalhados por ramos.</li>
<li><strong>O TypeScript garante que não falta nenhum:</strong> com <code>Record&lt;TranslationError[&#39;reason&#39;], string&gt;</code>, se amanhã aparecer um motivo de erro novo, o código não compila até ter o seu texto.</li>
<li><strong>Acrescentar um caso é acrescentar uma linha</strong>, sem mexer na lógica.</li>
</ul>
<p>Se em vez de um valor precisas de executar algo, os valores passam a ser funções: é a <em>dispatch table</em>. Há aqui uma armadilha que eu próprio tinha nos meus apontamentos:</p>
<pre><code class="language-javascript">const LOKI_DEFAULT_DISFARCE = &#39;Loki&#39;;

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

// ❌ Se o inimigo não estiver na tabela: TypeError, ... is not a function
const disfarce = LOKI_DISFARCES[enemy]() ? LOKI_DISFARCES[enemy]() : LOKI_DEFAULT_DISFARCE;

// ✅ Com ?.() nada é executado se não existir, e ?? põe o valor por defeito
const disfarce = LOKI_DISFARCES[enemy]?.() ?? LOKI_DEFAULT_DISFARCE;
</code></pre>
<p>Além de partir com uma chave desconhecida, a primeira versão executa a função duas vezes. E <code>??</code> é melhor do que o ternário: só usa o valor por defeito quando a chave não existe, não quando o resultado é um <code>&#39;&#39;</code> ou um <code>0</code> válidos.</p>
<p>Não é dogma: nos templates uso <code>@switch</code> quando cada caso pinta algo diferente, e ainda fica algum <code>switch</code> onde cada ramo faz mais do que devolver um valor.</p>
<p><strong>E os testes:</strong></p>
<ul>
<li><strong>857 casos</strong> em 117 ficheiros, com Vitest e JSDOM, também para as funções de <code>api/</code>.</li>
<li><strong>Commits convencionais</strong> validados por um script.</li>
</ul>
<hr>
<h2>🐛 Cinco problemas reais (e como os resolvi)</h2>
<h3>1. Separadores que se partiam depois de cada deploy</h3>
<p>Um separador aberto antes de um deploy pedia ficheiros do build anterior, que já não existiam. As páginas que carregam ao serem visitadas (galeria, Sobre, o painel) <strong>não abriam e não diziam nada</strong>.</p>
<p>A solução foi um gestor de erros de navegação que, se falhar o download de uma parte da app, recarrega essa página do zero para obter o build novo. Só o faz para erros de download e uma única vez seguida por página, por isso uma falha real não pode provocar um ciclo de recarregamentos.</p>
<h3>2. Tirar o Firebase das páginas públicas</h3>
<p>A poupança está contada na secção de desempenho; o delicado foi a segurança. Separei o login num store que é carregado a pedido, e a marca local que decide quando o carregar <strong>não concede nada</strong>: se alguém a falsificar, só consegue descarregar o Firebase Auth, porque a verificação real continua a ser o login e o documento de admin no Firestore.</p>
<h3>3. As funções da Vercel e os módulos ES</h3>
<p>As funções de <code>api/</code> falhavam na Vercel: como módulos ES nativos, os imports relativos precisam da extensão <code>.js</code>. Além da correção, acrescentei um teste que verifica todos os imports de <code>api/</code> para que não volte a acontecer.</p>
<h3>4. Uma limpeza de fotos que não se pode enganar</h3>
<p>Apagar fotos &quot;órfãs&quot; é fácil; apagar só as órfãs, nem tanto. A limpeza foi aprendendo com cada caso:</p>
<ul>
<li>Se não conseguir ler as séries, <strong>não apaga nada</strong>. Uma leitura falhada não significa que não haja fotos em uso.</li>
<li>Só dá como apagadas as fotos que a Cloudinary <strong>confirma</strong>.</li>
<li>Respeita a foto de perfil e as do cantinho do admin, que vivem na mesma pasta.</li>
<li>Não toca em fotos com menos de 7 dias e apaga no máximo 100 por execução.</li>
</ul>
<h3>5. &quot;A guardar…&quot; para sempre</h3>
<p>Ao guardar a foto de perfil com uma ligação lenta, se o guardar demorasse demasiado a app perguntava ao servidor se tinha chegado... sem limite de tempo para essa pergunta. Agora essa verificação tem o seu próprio limite de 5 segundos, e a leitura da sessão guardada no IndexedDB desiste aos 2 segundos, porque alguns navegadores nunca respondem.</p>
<hr>
<h2>📅 Dia a dia</h2>
<table>
<thead>
<tr>
<th>Dia</th>
<th>Commits</th>
<th>O que aconteceu</th>
</tr>
</thead>
<tbody><tr>
<td>13 out</td>
<td>1</td>
<td>Projeto criado: Angular, i18n e ferramentas.</td>
</tr>
<tr>
<td>14 out</td>
<td>43</td>
<td>Protótipo completo do site público e fotos a partir da Cloudinary.</td>
</tr>
<tr>
<td>15 out</td>
<td>75</td>
<td>Regras do Firestore, login, uploads assinados, editor, tradução, temas, limpeza, página 404. <strong>Primeira entrada em produção.</strong></td>
</tr>
<tr>
<td>16 out</td>
<td>3</td>
<td>Tipos de letra próprios.</td>
</tr>
<tr>
<td>19 out</td>
<td>22</td>
<td>Páginas públicas sem o SDK, login a pedido, definições do painel.</td>
</tr>
<tr>
<td>20 out</td>
<td>7</td>
<td>A história do Sobre e ajustes em telemóvel.</td>
</tr>
<tr>
<td>21 out</td>
<td>23</td>
<td>Ícone do Instagram, o cantinho do admin e o formulário de contacto.</td>
</tr>
<tr>
<td>22 out</td>
<td>3</td>
<td>Afinação do email de contacto.</td>
</tr>
<tr>
<td>26 out</td>
<td>12</td>
<td>Painel adaptado ao telemóvel, mostrar a palavra-passe no login e melhorias no cantinho do admin.</td>
</tr>
</tbody></table>
<hr>
<h2>🎯 Lições</h2>
<ol>
<li><strong>A velocidade vem do método, não só da IA.</strong> Requisitos por escrito, testes primeiro, ramos e revisão: sem isso, 75 commits num dia seriam 75 problemas.</li>
<li><strong>A memória persistente muda o jogo.</strong> Um agente que se lembra de decisões e erros de sessões anteriores não os repete.</li>
<li><strong>Verificar vale mais do que gerar.</strong> Nada chegou a produção sem passar testes, lint e build.</li>
<li><strong>Os serviços gratuitos dão para muito</strong> se desenhares a pensar nos seus limites (Cloudinary, Firebase, DeepL Free, Resend e Vercel).</li>
<li><strong>A segurança não se delega no frontend.</strong> As regras do Firestore e as funções que verificam o admin é que protegem; a interface só acompanha.</li>
</ol>
<blockquote>
<p>⭐ <strong>gentle-ai no GitHub:</strong> <a href="https://github.com/Gentleman-Programming/gentle-ai">github.com/Gentleman-Programming/gentle-ai</a>. Se te for útil, dá-lhe uma estrela.</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>O YouTube não reproduz no meu Mac: cinco falsas pistas e um culpado 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>Como um hub USB-C deixou o áudio HDMI do meu Mac bloqueado, e porque é que o YouTube ficava no 0:00 enquanto eu culpava a rede 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_PT_dark.webp"/><enclosure length="90936" type="image/webp" url="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_PT_dark.webp"/><content:encoded><![CDATA[<p><img alt="O YouTube não reproduz no meu Mac: cinco falsas pistas e um culpado inesperado." src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_PT_dark.webp"></p><h2>🎬 Introdução: a página carrega, o vídeo não</h2>
<p>O meu MacBook da empresa funcionava na perfeição há quatro anos com este esquema: um hub USB-C (Ethernet, webcam e uma saída VGA para uma televisão) e um monitor externo por HDMI cujo áudio utilizo, com a ficha de auscultadores do monitor a alimentar as colunas da televisão. Até que um dia o YouTube deixou de reproduzir: a página carregava na perfeição, mas o vídeo ficava a rodar para sempre no <strong>0:00</strong>.</p>
<p>Esta é a minha secretária:</p>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/illustration_PT_dark.webp" alt="Ilustração da minha secretária: MacBook Pro com hub USB-C, monitor VS278 por HDMI com o áudio bloqueado e uma televisão como segundo ecrã"><p>O padrão era o que mais desconcertava. Acontecia em casa, mas não na minha segunda habitação, com outro ISP. E um PC com Linux na mesma rede doméstica, com o mesmo navegador, funcionava sem problemas. Tudo apontava para a <strong>rede ou o fornecedor de internet</strong>, juntamente com um Mac gerido pela empresa (MDM, um cliente SASE de <em>secure web gateway</em>, um perfil de DNS cifrado gerido e um agente de segurança de endpoint). Suspeitos a mais.</p>
<blockquote>
<p>💡 Spoiler: a rede era inocente. Mas demorei horas a aceitá-lo, e o caminho é o que vale a pena contar.</p>
</blockquote>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/layers_PT_dark.webp" alt="As camadas que suspeitámos e a que realmente falhava"><hr>
<h2>🤖 Como trabalhei: a iterar com agentes</h2>
<p>Não fiz esta investigação sozinho. Trabalhei no terminal com agentes de IA (Claude Code) configurados com o <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>, uma ferramenta de código aberto (<a href="https://gentle-ai.gentlemanprogramming.com/">site oficial</a>) que configura os agentes que já usas com memória persistente, Organic-Driven Development, skills e orquestração de sub-agentes, sem te prender a nenhum.</p>
<p><strong>O que o gentle-ai trouxe neste caso:</strong></p>
<ul>
<li><strong>Engram:</strong> memória persistente entre sessões, para não começar do zero de cada vez.</li>
<li><strong>ODD (Organic-Driven Development):</strong> um fluxo leve, com documento de tarefas para o trabalho substancial, ramo antes de escrever e nenhum commit sem a minha permissão explícita.</li>
<li><strong>Orquestração de sub-agentes:</strong> o orquestrador delegou em escritores que redigiram este artigo em três idiomas e geraram os diagramas; eu revi e corrigi.</li>
</ul>
<p>O gentle-ai foi criado por <a href="https://www.linkedin.com/in/alanbuscaglia/">Alan Buscaglia</a> (Gentleman Programming), e a regra dele para trabalhar com IA resume bem este artigo: <em>&quot;verifying beats generating&quot;</em>, ou seja, verificar vale mais do que gerar.</p>
<blockquote>
<p>⭐ <strong>gentle-ai no GitHub:</strong> <a href="https://github.com/Gentleman-Programming/gentle-ai">github.com/Gentleman-Programming/gentle-ai</a>. Se te for útil, dá-lhe uma estrela: ajuda mais pessoas a encontrá-lo.</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>O método foi sempre o mesmo, e enganámo-nos os dois: o agente também defendeu teorias erradas (a do cliente SASE e da cache do ISP foi dele) e a evidência corrigiu-o tal como a mim.</p>
<p><strong>hipótese → teste → evidência → descartar</strong></p>
<ul>
<li>🤖 <strong>O agente:</strong> leu logs de sistema e de rede, inspecionou configurações, capturou e descodificou o tráfego do leitor, lançou o Chrome sem interface pelo DevTools, montou o monitor de reprodução, executou os testes de isolamento e escreveu o vigilante em Swift com o respetivo LaunchAgent.</li>
<li>🙋 <strong>Eu:</strong> dei o contexto e as capturas de ecrã, fiz as verificações no navegador que o agente não podia e, sobretudo, reparei que falhava mesmo quando ligava o hub USB-C e confirmei que o esquema funcionava assim há quatro anos.</li>
</ul>
<hr>
<h2>🕵️ Falsa pista n.º 1: o filtro DNS de casa</h2>
<p>Primeiro o mais óbvio: o Pi-hole lá de casa. Descartámo-lo antes de começar a sério, com provas: o DNS resolvia tudo corretamente e só bloqueava domínios de publicidade. Nada a ver com os servidores de vídeo.</p>
<hr>
<h2>🚦 Falsa pista n.º 2: QUIC / HTTP3</h2>
<p>O clássico &quot;a página carrega mas o vídeo não&quot;: uma firewall ou um fornecedor a quebrar o QUIC. Desativámos o QUIC no navegador (<code>brave://flags/#enable-quic</code>) e o vídeo <strong>continuou a falhar</strong>. Descartado.</p>
<hr>
<h2>🧩 Falsa pista n.º 3: bloqueadores e extensões</h2>
<p>Na consola apareciam erros <code>net::ERR_BLOCKED_BY_CLIENT</code> na telemetria do YouTube (<code>qoe</code>, <code>log_event</code>, <code>generate_204</code>). Muito suspeito... até experimentarmos numa janela privada com os <em>shields</em> desligados: a telemetria passava e o vídeo continuava preso. Esses erros eram <strong>ruído</strong>.</p>
<hr>
<h2>🛡️ Falsa pista n.º 4: o cliente SASE</h2>
<p>Esta foi a mais convincente. A extensão de rede do cliente SASE continuava a correr mesmo quando a &quot;desativava&quot; na interface (proteção anti-adulteração), e os seus logs, lidos pelo agente, mostravam que tratava fluxos do navegador para <code>googlevideo.com</code>. Além disso, o meu fornecedor servia o vídeo a partir de um nó de cache da Google <strong>dentro da sua própria rede</strong>. A hipótese, que foi do próprio agente, encaixava na perfeição: o tráfego que sai pela cloud do SASE é rejeitado pela cache do ISP.</p>
<p>Três factos desmontaram-na:</p>
<ul>
<li>O certificado que o navegador recebia era o <strong>genuíno da Google</strong>: não havia interceção TLS.</li>
<li>O nó de cache respondia sem problemas (um <code>204</code> correto).</li>
<li>E o decisivo: o <strong>Chrome nem sequer passa por esse cliente</strong> (zero fluxos no log) e falhava exatamente da mesma forma.</li>
</ul>
<hr>
<h2>👻 Falsa pista n.º 5: a rota &quot;fantasma&quot; de IPv6</h2>
<p>Outro assistente de IA sugeriu um problema de IPv6. O agente verificou, e depois retratou a ideia: o Mac não tinha IPv6 global, as rotas por defeito <code>utun</code> estavam associadas à interface (são túneis do sistema), a tentativa IPv6 do navegador falhava de imediato e recuava para IPv4, e a minha rede doméstica nem sequer tem IPv6. Descartado. Também não eram a conta nem os cookies: um perfil de Chrome totalmente limpo, sem cookies, sem conta e sem extensões, também ficava preso.</p>
<hr>
<h2>🔬 A pista à vista de todos</h2>
<p>Durante todo o processo houve uma observação que, em retrospetiva, apontava para longe da rede. A resposta do YouTube era <strong>válida</strong>: um stream SABR/UMP com cerca de 1 MB de áudio e vídeo reais e o estado de proteção do stream em OK. Os bytes chegavam depressa. Mas o leitor <strong>nunca os consumia</strong>: <code>video.readyState</code> ficava a 0, <code>buffered</code> vazio e o leitor no estado 3 (<em>buffering</em>).</p>
<blockquote>
<p><em>&quot;Os dados chegam, mas o leitor não arranca.&quot;</em> Isso não é um problema de rede.</p>
</blockquote>
<hr>
<h2>🔁 A reviravolta: torná-lo reproduzível</h2>
<p>Depois de reiniciar o Mac funcionou... e mais tarde voltou a avariar. Por isso deixei de adivinhar e pedi ao agente que montasse um <strong>monitor</strong>: de 2 em 2 minutos lançava um Chrome limpo e sem interface através do protocolo DevTools, carregava um vídeo, verificava <code>readyState</code> e <code>currentTime</code>, e guardava uma fotografia do sistema a cada mudança de estado.</p>
<p>Foi então que reparei, e esta parte foi minha, não do agente: <strong>avariava exatamente quando ligava o hub USB-C</strong>, num esquema que eu sabia funcionar assim há quatro anos. O agente fez um teste de isolamento com o hub ligado:</p>
<table>
<thead>
<tr>
<th>Saída de áudio</th>
<th>Chrome com <code>--disable-audio-output</code></th>
<th>Chrome com áudio</th>
</tr>
</thead>
<tbody><tr>
<td>Colunas do MacBook</td>
<td>OK</td>
<td>OK</td>
</tr>
<tr>
<td>Monitor HDMI (VS278)</td>
<td>OK</td>
<td>FALHA (preso no 0:00)</td>
</tr>
</tbody></table>
<p>Aí estava. Sem áudio o vídeo reproduz; com áudio para o monitor HDMI, não.</p>
<hr>
<h2>🎯 A causa raiz: um dispositivo de áudio bloqueado</h2>
<p>Ao ligar o hub, o macOS reconfigura os ecrãs e os dispositivos de áudio, e o áudio do monitor HDMI fica <strong>bloqueado</strong>, embora nunca desapareça da lista de dispositivos. O Chromium não inicia a reprodução enquanto não conseguir abrir a saída de áudio, por isso o vídeo espera para sempre, apesar de os dados já terem chegado.</p>
<p>Esta é a minha montagem, com o ponto exato onde falhou:</p>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/setup_PT_dark.webp" alt="A minha montagem: onde estava a falha"><p>Isto explica também o resto do mistério:</p>
<ul>
<li>📍 <strong>&quot;Só acontece em casa&quot;</strong>: é lá que está o esquema do hub e do monitor. A rede era uma pista falsa; a correlação com o local era, na verdade, uma correlação com o <strong>hardware</strong>.</li>
<li>⏱️ <strong>&quot;Avaria de vez em quando&quot;</strong>: o hub é um bocado instável e mexe-se-lhe. Cada nova ligação volta a disparar o problema.</li>
</ul>
<hr>
<h2>🔧 A solução manual</h2>
<p>Basta <strong>reinicializar o dispositivo de áudio</strong>. Três formas:</p>
<ol>
<li>Abrir a <em>Configuração de MIDI e Áudio</em>, selecionar o dispositivo HDMI e mudar o formato de 48 000 Hz para 44 100 Hz e voltar.</li>
<li>Mudar a saída de som para outro dispositivo e devolvê-la.</li>
<li>Reiniciar o serviço de áudio: <code>sudo killall coreaudiod</code>.</li>
</ol>
<p>Uma contraprova confirmou que o que resolve é o <strong>reinício</strong>, não a frequência: os 48 kHz voltam a funcionar depois de um reset. Fixar uma frequência concreta <strong>não</strong> ajuda, porque o macOS já a memoriza por dispositivo.</p>
<hr>
<h2>🤖 A solução automática</h2>
<p>Como o problema voltava a cada nova ligação, pedi ao agente que o automatizasse e ele escreveu um pequeno programa em Swift que usa o CoreAudio e corre como LaunchAgent de utilizador; revi-o e instalei-o eu. Escuta as alterações em <code>kAudioHardwarePropertyDevices</code>, espera 4 segundos até tudo assentar (<em>debounce</em>) e alterna a frequência nominal do dispositivo alvo para outra disponível e de volta. A primeira versão não disparava, porque o monitor nunca desaparece da lista de dispositivos; o agente corrigiu-a para reagir a qualquer alteração nessa lista.</p>
<h3>🧾 O 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>📄 O LaunchAgent (<code>~/Library/LaunchAgents/local.hdmi-audio-reset.plist</code>)</h3>
<p>Substitui <code>YOUR_USER</code> pelo teu utilizador e <code>VS278</code> pelo nome do teu dispositivo de áudio.</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 desinstalar:</p>
<pre><code class="language-bash">launchctl bootout gui/$(id -u)/local.hdmi-audio-reset
</code></pre>
<p>Verifiquei-o de ponta a ponta: desliguei e voltei a ligar o hub, o log mostrou <code>audio devices changed; resetting …</code>, depois o reinício, e o YouTube reproduziu sem eu tocar em nada.</p>
<hr>
<h2>🧠 Lições</h2>
<ul>
<li><strong>Descarta camadas com provas, não com intuição.</strong> Cada falsa pista foi encerrada com um teste concreto, não com um palpite.</li>
<li><strong>Desconfia da primeira história plausível.</strong> O agente e eu perseguimos a rede durante horas porque a história encaixava bem demais (a teoria do SASE e da cache do ISP foi do próprio agente).</li>
<li><strong>&quot;Os dados chegam mas nada toca&quot; quer dizer olhar para o pipeline multimédia</strong> (áudio, descodificador), não para a rede.</li>
<li><strong>Torna a falha reproduzível e automática.</strong> Um monitor de 2 em 2 minutos deu-me o que a intuição não conseguiu: o gatilho.</li>
<li><strong>Muda uma variável de cada vez.</strong> O teste de isolamento da tabela acima resolveu em minutos o que horas de hipóteses não resolveram.</li>
<li><strong>A correlação com o local era, na verdade, uma correlação com um esquema de hardware.</strong></li>
</ul>
<p>Fiz a investigação a iterar com agentes de IA no terminal (Claude Code, configurado com o <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>): executaram e organizaram os testes, e caíram em teorias erradas tal como eu. Este artigo também foi redigido por sub-agentes orquestrados pelo gentle-ai (o texto em três idiomas e as ilustrações), e fui eu que o revi e corrigi. O que nos fez avançar aos dois foi descartar com provas.</p>
<p>Se tens um Mac, um hub USB-C e áudio por HDMI, e o YouTube fica no 0:00, experimenta primeiro reinicializar o dispositivo de áudio. Pode poupar-te uma tarde inteira. 🎧</p>
]]></content:encoded></item>
<item><title>Código Impecável em Equipes Multi-Consultoria.</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>Como Prettier, Husky e lint-staged eliminam as guerras de estilo em repositórios com múltiplas consultorias sem sacrificar o histórico do 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_PT.webp"/><enclosure length="258784" type="image/webp" url="https://rootdevs.es/assets/images/blog/prettierPost/infographic_PT.webp"/><content:encoded><![CDATA[<p><img alt="Código Impecável em Equipes Multi-Consultoria." src="https://rootdevs.es/assets/images/blog/prettierPost/infographic_PT.webp"></p><h2>🌪️ Introdução: O Caos Invisível dos Espaços e das Vírgulas</h2>
<p>Em projetos de grande dimensão onde coexistem múltiplas consultoras, a diversidade de critérios não é apenas uma riqueza cultural — é também um risco técnico. Surge um problema tão silencioso quanto perturbador: <strong>a inconsistência visual</strong>. Quando cada equipa traz o seu próprio ADN de desenvolvimento — uns preferem tabulações, outros espaços; aspas simples versus aspas duplas — o repositório deixa de ser um ativo coeso para se tornar um campo de batalha de alterações irrelevantes.</p>
<p>Esta falta de unificação não é um mero detalhe estético; é uma fonte de fricção operacional e de <em>vendor finger-pointing</em> — apontar o dedo entre fornecedores — durante as revisões de código. As discussões sobre a posição de uma chaveta ou o comprimento de uma linha <strong>roubam minutos de ouro</strong> que deveriam ser investidos na arquitetura ou na lógica de negócio.</p>
<blockquote>
<p>💡 Para um líder técnico, standardizar não é um capricho: é uma estratégia para eliminar o ruído e garantir que as Code Reviews se centrem no que realmente acrescenta valor.</p>
</blockquote>
<hr>
<h2>✨ O Fim das Guerras de Estilo com o Prettier</h2>
<p>A solução para erradicar estas fricções é delegar a responsabilidade ao <strong>Prettier</strong>. Como formatador de código <em>opinionated</em>, o Prettier elimina a subjetividade ao fazer o parse do código e reimprimi-lo sob regras estritas e predefinidas.</p>
<p>O que o torna indispensável para um Arquiteto de Software é a sua <strong>segurança</strong>: o Prettier atua sobre a camada de apresentação sem alterar a Árvore de Sintaxe Abstrata (AST). Isto oferece uma garantia fundamental:</p>
<blockquote>
<p><em>&quot;O funcionamento da aplicação mantém-se exatamente igual&quot;</em></p>
</blockquote>
<p>Adotar o Prettier é, em essência, um ato de libertação para a equipa. Ao automatizar o estilo, o programador liberta-se da carga cognitiva de &quot;encaixar&quot; visualmente com os restantes, permitindo que a <strong>Developer Experience (DevEx)</strong> flua para a resolução de problemas complexos.</p>
<h3>⚙️ Configuração mínima em <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>🕵️ O &quot;Hack&quot; do Git para Formatar sem Perder o Rasto</h2>
<p>O maior receio ao implementar um standard num projeto em produção é o <strong>commit massivo</strong>. Ao formatar toda a base de código de uma só vez, corremos o risco de obscurecer a autoria original. Se um programador usar o <code>git blame</code> para perceber porque razão foi tomada uma decisão técnica há dois anos, encontrará como autor <em>&quot;o bot de formatação&quot;</em>, perdendo a rastreabilidade histórica.</p>
<p>Para evoluir a base de código sem sacrificar este rasto, utilizamos o ficheiro <code>.git-blame-ignore-revs</code>.</p>
<h3>🔍 Como funciona</h3>
<ol>
<li>Formatamos o projeto completo e fazemos commit.</li>
<li>Obtemos o hash do commit de formatação.</li>
<li>Registamo-lo em <code>.git-blame-ignore-revs</code>:</li>
</ol>
<pre><code class="language-bash"># Commit de formatação massiva com Prettier — 2024-10-15
a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
</code></pre>
<ol start="4">
<li>Configuramos o Git para que o utilize automaticamente:</li>
</ol>
<pre><code class="language-bash">git config blame.ignoreRevsFile .git-blame-ignore-revs
</code></pre>
<p>Esta funcionalidade permite:</p>
<ul>
<li>👤 <strong>Preservar a autoria original</strong>: o Git ignora esse commit nos rastreios.</li>
<li>🏛️ <strong>Integridade do historial</strong>: o GitHub e o GitLab respeitam este ficheiro, mostrando o verdadeiro autor da lógica de negócio.</li>
<li>🔇 <strong>Evolução sem ruído</strong>: alcança-se a perfeição visual sem <em>&quot;sujar&quot;</em> a responsabilidade histórica de cada linha.</li>
</ul>
<hr>
<h2>🌐 Node.js como a &quot;Fonte da Verdade&quot; Multiplataforma</h2>
<p>Em ecossistemas modernos, especialmente em projetos baseados em Angular, a independência do sistema operativo é crítica. O erro clássico de muitas automatizações é depender de scripts em <strong>Bash</strong>, que frequentemente falham em ambientes Windows ou requerem configurações manuais fastidiosas.</p>
<p>A nossa arquitetura propõe uma mudança de paradigma: <strong>tratar o repositório como um ambiente de aplicação em vez de um ambiente de sistema</strong>. Ao centralizar tudo em Node.js, garantimos um comportamento verdadeiramente <em>cross-platform</em>:</p>
<ul>
<li>💻 <strong>Agnóstico de hardware</strong>: o fluxo funciona da mesma forma num MacBook M3 que numa estação de trabalho com Windows 10.</li>
<li>🔒 <strong>Ambiente controlado</strong>: se o programador tiver o Node.js instalado (requisito para Angular), a automatização funcionará por defeito.</li>
<li>📦 <strong>Unificação de ferramentas</strong>: o <code>package.json</code> torna-se a única fonte da verdade.</li>
</ul>
<hr>
<h2>🐶 Husky e lint-staged: Os Guardiões Silenciosos do Repositório</h2>
<p>Para que a standardização seja eficaz, deve ser <strong>invisível e obrigatória</strong>. Implementamos um fluxo de conformidade forçada através do <strong>Husky</strong> e do <strong>lint-staged</strong>, atuando como um filtro de qualidade antes de o código chegar ao servidor:</p>
<ol>
<li>🚀 <strong>Ativação</strong>: o programador executa <code>git commit</code>.</li>
<li>🪝 <strong>Intercepção</strong>: o Husky captura o evento e delega a execução ao Node.js.</li>
<li>🎯 <strong>Filtragem inteligente</strong>: o lint-staged identifica apenas os ficheiros na <em>Staging Area</em>. Não perdemos tempo a processar milhares de ficheiros estáticos — apenas o que foi alterado.</li>
<li>✅ <strong>Formatação e validação</strong>: o Prettier aplica o formato. Se o código tiver erros de sintaxe graves, o commit é abortado.</li>
</ol>
<h3>📦 Instalação</h3>
<pre><code class="language-bash">npm install --save-dev husky lint-staged
npx husky init
</code></pre>
<h3>🔧 Configuração em <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 processo elimina o <strong>fator do esquecimento humano</strong> e garante que nenhum código fora do standard entra alguma vez no repositório, sem que o programador tenha de executar um único comando adicional.</p>
</blockquote>
<hr>
<h2>📋 Plano de Ação: Prova de Conceito (POC)</h2>
<p>Como Arquitetos, não implementamos alterações críticas às cegas. O caminho para a excelência visual segue um roteiro técnico claro:</p>
<ol>
<li>🌿 <strong>Isolamento</strong>: criar um ramo <code>feature/standardizacao-prettier</code> para não interromper o fluxo atual.</li>
<li>⚙️ <strong>Configuração</strong>: instalar a stack (Prettier, Husky, lint-staged) e definir as regras em <code>.prettierrc</code>.</li>
<li>💥 <strong>Execução massiva</strong>: formatar o projeto completo e gerar o commit de referência.</li>
<li>🛡️ <strong>Proteção do historial</strong>: configurar o ficheiro <code>.git-blame-ignore-revs</code> com o hash obtido.</li>
<li>🎬 <strong>Demonstração ao vivo</strong>: mostrar à equipa e ao cliente como código mal indentado em Windows é corrigido instantaneamente ao fazer commit, mantendo a autoria original no historial.</li>
</ol>
<hr>
<h2>🚀 Conclusão: Em Direção a uma Cultura de Código Limpo</h2>
<p>A adoção deste ecossistema não é apenas uma melhoria técnica; é uma <strong>declaração de princípios</strong> sobre a qualidade. Os benefícios são imediatos: revisões de código mais ágeis, um historial de Git impecável e a eliminação total da fricção estética entre consultoras.</p>
<p>O investimento para implementar esta arquitetura é mínimo face à poupança de tempo e frustração a longo prazo. No fim do dia, a pergunta para qualquer líder tecnológico é simples:</p>
<blockquote>
<p>Quanto tempo está a sua equipa a perder a discutir o formato das vírgulas, quando poderia estar a fortalecer a arquitetura do seu sistema?</p>
</blockquote>
<p>A automatização é a ponte entre o caos visual e a excelência técnica. E feliz 4 de Maio, que a força esteja convosco em cada commit! 🌌⚔️</p>
]]></content:encoded></item>
</channel>
</rss>
