<?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>Technical posts by Francisco Javier Antúnez Durán about Angular, performance, tooling and AI.</description>
<language>en</language>
<lastBuildDate>Tue, 27 Oct 2026 00:00:00 GMT</lastBuildDate>
<atom:link href="https://rootdevs.es/rss/en.xml" rel="self" type="application/rss+xml"/>
<item><title>How I built Framed by Santi: from zero to production in three days.</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>A photography portfolio with a private dashboard, three languages and a perfect Lighthouse score, built with Claude Code as my assistant: the stack, the features, the real problems and the method to move fast without losing quality.</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_EN_dark.webp"/><enclosure length="71808" type="image/webp" url="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_EN_dark.webp"/><content:encoded><![CDATA[<p><img alt="How I built Framed by Santi: from zero to production in three days." src="https://rootdevs.es/assets/images/blog/framedBySanti/infographic_EN_dark.webp"></p><h2>📸 Introduction: a photography portfolio in production on day three</h2>
<p><a href="https://framedbysanti.rootdevs.es">Framed by Santi</a> is the website of <a href="https://www.instagram.com/framedbysanti">@framedbysanti</a>, an amateur photographer who shoots the streets of New York with a Fujifilm X100VI. It has two sides: a public site to show his photos and a private dashboard so he can publish on his own, without depending on anyone.</p>
<p>It started as a gift for a friend, but this post isn&#39;t about that: it&#39;s about <strong>how I built it in very little time, assisted by Claude Code, without giving up on quality</strong>.</p>
<p>The numbers, straight from the repository:</p>
<ul>
<li>📅 <strong>9 days with commits</strong>, from October 13 to 26, 2026.</li>
<li>🚀 <strong>In production on day 3</strong>: public site and private dashboard.</li>
<li>🔀 <strong>189 commits</strong> and <strong>35 pull requests</strong>; I tested every change myself, as a visitor and as the admin, before it reached production.</li>
<li>🧪 <strong>117 test files</strong> with <strong>857 cases</strong>.</li>
<li>🌍 <strong>Three languages</strong>: English, Spanish and Portuguese.</li>
<li>💯 <strong>100 in all four Lighthouse categories</strong> (desktop, 4G network).</li>
</ul>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/framedBySanti/commits_EN_dark.webp" alt="Bar chart of commits per day from October 13 to 26, 2026: 1, 43, 75 (the day it went to production), 3, none over the weekend, 22, 7, 23, 3, none from the 23rd to the 25th and 12"><hr>
<h2>🤖 How I worked: AI agents, but with a method</h2>
<p>I built it in the terminal with AI agents (Claude Code) configured through <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>, the open-source tool by <a href="https://www.linkedin.com/in/alanbuscaglia/">Alan Buscaglia</a> (Gentleman Programming). This isn&#39;t &quot;asking the AI for a website&quot;: it&#39;s a workflow with rules.</p>
<ul>
<li><strong>ODD (Organic-Driven Development):</strong> small changes are made directly; big work gets a task document with the requirements copied word for word, so nothing gets lost between sessions.</li>
<li><strong>Engram:</strong> persistent memory. Every decision, solved bug or preference is saved and the agent recovers it in the next session. For example: &quot;ask before pushing, even to staging&quot;.</li>
<li><strong>Tests first:</strong> for every new rule, first a failing test and then the code that makes it pass.</li>
<li><strong>Branches and review:</strong> every change starts on a branch, goes up to <code>develop</code>, I test it on <code>staging</code>, and it only reaches <code>main</code> (production) when I approve it.</li>
</ul>
<p>The split was clear:</p>
<ul>
<li>🤖 <strong>The agent:</strong> wrote the code and the tests, investigated the bugs, proposed solutions and opened the PRs.</li>
<li>🙋 <strong>Me:</strong> decided what to build and how it had to work, reviewed every change, corrected the agent when it got things wrong and approved every release to production.</li>
</ul>
<blockquote>
<p>💡 AI writes fast; what makes the result good is <strong>verifying</strong>. Every PR passed lint, tests and build, and no change reached production without my approval.</p>
</blockquote>
<hr>
<h2>🧱 Stack and architecture</h2>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/framedBySanti/architecture_EN_dark.webp" alt="Framed by Santi architecture diagram: the browser gets the static app from Vercel, reads Firestore over REST on public pages and uses Firebase Auth and the SDK only in the dashboard; photos come from the Cloudinary CDN and the dashboard uploads them directly with a signature; the Vercel functions clean up Cloudinary, send the email with Resend, translate with DeepL and sign the uploads, and all the dashboard ones check in Firestore that the user is an admin with their own token"><p><strong>Frontend</strong></p>
<ul>
<li><strong>Angular 22</strong> without Zone.js (<em>zoneless</em>), with <strong>Signals</strong> for all state and <em>standalone</em> components.</li>
<li><strong>TypeScript 6</strong> in strict mode: no <code>any</code> and explicit return types.</li>
<li><strong>Native CSS</strong> with <em>nesting</em>, BEM and <em>mobile-first</em>. No preprocessor.</li>
<li><strong>Vite 8</strong> with <code>@analogjs/vite-plugin-angular</code> as the only build tool.</li>
<li><strong>Transloco</strong> for the three languages.</li>
</ul>
<p><strong>Services</strong></p>
<ul>
<li><strong>Vercel:</strong> hosting and four <em>serverless</em> functions.</li>
<li><strong>Firebase Auth and Firestore:</strong> dashboard login and data (sets, themes, settings).</li>
<li><strong>Cloudinary:</strong> photo storage and delivery, with signed uploads.</li>
<li><strong>DeepL:</strong> automatic translation of the texts.</li>
<li><strong>Resend:</strong> the contact form email.</li>
</ul>
<p><strong>The four <code>api/</code> functions</strong></p>
<table>
<thead>
<tr>
<th>Function</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>sign-upload</code></td>
<td>Checks that the caller is an admin and signs the Cloudinary upload.</td>
</tr>
<tr>
<td><code>translate</code></td>
<td>Proxy to DeepL, admins only, with a 10-second limit.</td>
</tr>
<tr>
<td><code>cleanup-photos</code></td>
<td>Deletes from Cloudinary the photos nothing uses anymore.</td>
</tr>
<tr>
<td><code>contact</code></td>
<td>Validates the form and sends the email with Resend.</td>
</tr>
</tbody></table>
<p>The three dashboard functions check that the user is an admin the same way: they read their document in Firestore <strong>with the user&#39;s own token</strong>, so it&#39;s the Firestore security rules that decide, not a secret on the server.</p>
<hr>
<h2>🖼️ The public site</h2>
<ul>
<li><strong>Posts are sets</strong>, like an Instagram carousel: several photos with a title and a story.</li>
<li><strong>Home</strong> with the latest set in a draggable carousel.</li>
<li><strong>Gallery</strong> with a section per set and a theme filter.</li>
<li><strong>Full-screen viewer:</strong> zoom and pan (pinch, drag, double tap, wheel), swipe gestures, thumbnails, preloading of the next photo and a dissolve between photos.</li>
<li><strong>View Transitions</strong> between pages, with an iris effect like a camera aperture.</li>
<li><strong>About</strong> with Santi&#39;s story in his own words and a contact form. His email never reaches the page.</li>
<li><strong>Real languages:</strong> dates, numbers and plurals follow the active language (<code>en-US</code>, <code>es-ES</code>, <code>pt-PT</code>).</li>
<li>With <code>prefers-reduced-motion</code>, nothing animates.</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_EN.webp" alt="Framed by Santi home page: a full-screen photo of the Flatiron with Santi's name and the buttons to the gallery and Instagram" width="720"></a><br><em>The home page. Click to see it live.</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_EN.webp" alt="Framed by Santi gallery: theme filter and a set with its photo carousel" width="720"></a><br><em>The gallery with the theme filter.</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_EN.webp" alt="Framed by Santi viewer: a large photo with arrows, zoom controls and the thumbnail strip" width="720"></a><br><em>The viewer: zoom, arrows and thumbnails.</em></p><hr>
<h2>🔐 The private dashboard</h2>
<p>The dashboard lives at <code>/admin</code> and has no public link: you only get there by typing the path.</p>
<ul>
<li><strong>Set library:</strong> published or draft switch, edit and delete.</li>
<li><strong>Set editor:</strong> drag photos in, preview them, reorder them and write the texts in one tab per language.</li>
<li><strong>Photos prepared in the browser:</strong> before uploading, each photo is resized and recompressed to at most 6000 px and 10 MB, lowering the JPEG quality step by step until it fits. That way the limits of Cloudinary&#39;s free plan are respected without Santi having to think about it.</li>
<li><strong>Automatic translation:</strong> Santi writes in one language and, when he publishes, DeepL fills in the missing ones. Each language can be corrected by hand.</li>
<li><strong>Themes:</strong> created and deleted from the dashboard.</li>
<li><strong>Automatic cleanup:</strong> when the library opens, the Cloudinary photos no set uses anymore are deleted in the background.</li>
<li><strong>Settings:</strong> profile photo and password change.</li>
<li><strong>On the phone too:</strong> the dashboard fits the phone screen, and the sign-in form lets you show or hide the password.</li>
<li><strong>And an <em>easter egg</em></strong> just for the admin: a corner to keep going strong. 💪</li>
</ul>
<p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/dashboard-library_EN.webp" alt="Private dashboard library: the sets with their cover, the published and drafts filter, and the publish switch" width="720"><br><em>The library: each set is published or moved to draft with a switch.</em></p><p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/dashboard-editor_EN.webp" alt="Private dashboard set editor: the set's photos on the left and, on the right, the texts with one tab per language and the Translate button" width="720"><br><em>The editor: photos on the left, texts per language and the translate button on the right.</em></p><hr>
<h2>⚡ Performance: measure, cut and measure again</h2>
<p>I didn&#39;t leave performance for the end: I measured with Lighthouse, went after what weighed the most and measured again. This is how it looks today on desktop with a 4G network:</p>
<p align="center"><img src="https://rootdevs.es/assets/images/blog/framedBySanti/lighthouse.webp" alt="Lighthouse report for Framed by Santi on desktop with a 4G network: 100 in Performance, Accessibility, Best Practices and SEO" width="420"></p><h3>First round: from 44 to 73 on mobile</h3>
<p>The first measurement, on October 15 on mobile with slow 4G (the most demanding case), gave a performance score of <strong>44-48</strong>, with a CLS of <strong>0.333</strong> and an LCP of <strong>7.5 s</strong>. Three changes fixed almost all of it:</p>
<ul>
<li><strong>Preloading the home page&#39;s main photo</strong> from <code>index.html</code>, with <code>fetchpriority=&quot;high&quot;</code>. The preload script goes before any stylesheet, because a pending stylesheet blocks the scripts that come after it.</li>
<li><strong>The home page ships in the main bundle</strong>, without lazy loading, so there&#39;s no extra network round trip before the first page is painted.</li>
<li><strong>The footer is painted after the first navigation.</strong> It was responsible for all the CLS. The latest-set carousel also reserves its space while it loads.</li>
</ul>
<p>Result on mobile: <strong>performance 73, CLS 0.001 and LCP 5.6 s</strong>.</p>
<h3>Second round: removing what wasn&#39;t needed</h3>
<ul>
<li><strong>Self-hosted fonts.</strong> I serve them from the site&#39;s own domain, with no round trip to Google Fonts.</li>
<li><strong>Firebase out of the public pages.</strong> Reviewing the build, I saw that the Firebase bundle weighed <strong>563 kB (168 kB compressed)</strong> and was preloaded on every page, and that every visit also opened a Google authentication iframe of about <strong>93 KiB</strong>. All of that to read sets that are public. Now the public pages read Firestore with <code>fetch</code> against its REST API, with the same security rules.</li>
<li><strong>Login only loads when needed:</strong> a local flag says that an admin has already signed in on that browser. Visitors never download Firebase Auth. And the flag grants no permission: the real check is still Firebase.</li>
</ul>
<p>Result on desktop with a 4G network: <strong>100 in Performance, Accessibility, Best Practices and SEO</strong>.</p>
<h3>What comes as standard</h3>
<ul>
<li><strong>Responsive images:</strong> every photo is served from Cloudinary with <code>srcset</code> in five widths (480, 800, 1200, 1600 and 2400 px) and automatic format and quality.</li>
<li><strong>Every page loads when it&#39;s visited</strong> (<em>lazy loading</em>), except the home page.</li>
</ul>
<blockquote>
<p>💡 The lesson: <strong>measure before optimizing</strong>. The biggest CLS problem was a footer, and the biggest weight, a library visitors didn&#39;t even use.</p>
</blockquote>
<hr>
<h2>🛡️ Security</h2>
<ul>
<li><strong>Firestore rules that deny everything by default.</strong> The public can only read published sets, and settings only accept the allowed fields.</li>
<li><strong>Signed uploads:</strong> the browser never has permission to upload to Cloudinary on its own; every upload is signed by a function that first checks you&#39;re an admin.</li>
<li><strong>Contact form</strong> with length limits, escaped HTML and a trap field: bots fill it in, get an &quot;OK&quot; and nothing is sent.</li>
<li><strong>Staging isn&#39;t indexed:</strong> staging builds carry <code>noindex</code> and a <code>robots.txt</code> that disallows everything.</li>
</ul>
<hr>
<h2>🧪 Quality: order isn&#39;t negotiable, the linter enforces it</h2>
<p>When AI writes most of the code, conventions can&#39;t depend on someone remembering them. So I turned them into rules: if the code doesn&#39;t follow the order, <strong>lint fails</strong>, no matter who wrote it. The rules have been there since the first day of the project.</p>
<p><strong>Five custom ESLint rules</strong>, written for this project:</p>
<table>
<thead>
<tr>
<th>Rule</th>
<th>What it enforces</th>
<th>Fixes itself?</th>
</tr>
</thead>
<tbody><tr>
<td><code>component-decorator-order</code></td>
<td><code>@Component</code> properties always in the same order: <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><code>inject()</code> calls come before any other class property.</td>
<td>✅</td>
</tr>
<tr>
<td><code>alphabetical-component-imports</code></td>
<td>The component&#39;s <code>imports</code> array, in alphabetical order.</td>
<td>✅</td>
</tr>
<tr>
<td><code>alphabetical-inputs-signals</code></td>
<td><code>input()</code> and <code>signal()</code> declarations, in alphabetical order within their group.</td>
<td>❌</td>
</tr>
<tr>
<td><code>alphabetical-interface-members</code></td>
<td>Interface and type members, in alphabetical order.</td>
<td>❌</td>
</tr>
</tbody></table>
<p>The last two only warn, they don&#39;t reorder on their own: in interfaces, for example, moving members can break code that uses <em>spread</em> or <code>Object.assign</code>, so I&#39;d rather a person decide.</p>
<p><strong>And the rest of the order, also automatic:</strong></p>
<ul>
<li><strong>Imports</strong> grouped and sorted: Angular and external libraries first, then the internal aliases (<code>@core</code>, <code>@features</code>...) and the relative ones last.</li>
<li><strong>Object keys</strong> in alphabetical order in data, constants, models and interfaces.</li>
<li><strong>Translation JSON</strong> with sorted keys and no comments.</li>
<li><strong>Prettier</strong> with plugins that sort CSS properties, HTML attributes and JSON keys.</li>
<li><strong>Strict TypeScript:</strong> <code>any</code> is an error, return types are required and <code>===</code> is always used.</li>
</ul>
<h3>The dictionary pattern</h3>
<p>There&#39;s one practice I always ask for that no linter enforces: the <strong>dictionary pattern</strong>, known in English as <em>object lookup</em> (and as a <em>dispatch table</em> when the values are functions). When a function maps one value to another, no <code>switch</code> and no chained <code>if/else</code>: an object typed with <code>Record</code>, key access and a default value with <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>Three advantages:</p>
<ul>
<li><strong>It reads at a glance:</strong> every case in one table, instead of spread across branches.</li>
<li><strong>TypeScript makes sure none is missing:</strong> with <code>Record&lt;TranslationError[&#39;reason&#39;], string&gt;</code>, if a new error reason shows up tomorrow, the code won&#39;t compile until it has its text.</li>
<li><strong>Adding a case is adding a line</strong>, without touching the logic.</li>
</ul>
<p>If instead of a value you need to run something, the values become functions: that&#39;s the <em>dispatch table</em>. There&#39;s a trap here that I had in my own notes:</p>
<pre><code class="language-javascript">const LOKI_DEFAULT_DISGUISE = &#39;Loki&#39;;

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

// ❌ If the enemy isn&#39;t in the table: TypeError, ... is not a function
const disguise = LOKI_DISGUISES[enemy]() ? LOKI_DISGUISES[enemy]() : LOKI_DEFAULT_DISGUISE;

// ✅ With ?.() nothing runs if it doesn&#39;t exist, and ?? sets the default value
const disguise = LOKI_DISGUISES[enemy]?.() ?? LOKI_DEFAULT_DISGUISE;
</code></pre>
<p>Besides breaking on an unknown key, the first version runs the function twice. And <code>??</code> is better than the ternary: it only uses the default value when the key doesn&#39;t exist, not when the result is a valid <code>&#39;&#39;</code> or <code>0</code>.</p>
<p>It isn&#39;t dogma: in templates I use <code>@switch</code> when each case renders something different, and there&#39;s still the odd <code>switch</code> where each branch does more than return a value.</p>
<p><strong>And the tests:</strong></p>
<ul>
<li><strong>857 cases</strong> in 117 files, with Vitest and JSDOM, the <code>api/</code> functions included.</li>
<li><strong>Conventional commits</strong> validated by a script.</li>
</ul>
<hr>
<h2>🐛 Five real problems (and how I solved them)</h2>
<h3>1. Tabs that broke after every deploy</h3>
<p>A tab opened before a deploy asked for files from the previous build, which no longer existed. The pages that load when you visit them (gallery, About, the dashboard) <strong>wouldn&#39;t open and said nothing</strong>.</p>
<p>The fix was a navigation error handler that, if downloading a part of the app fails, reloads that page from scratch to get the new build. It only does it for download errors and once in a row per page, so a real outage can&#39;t cause a reload loop.</p>
<h3>2. Taking Firebase off the public pages</h3>
<p>The savings are covered in the performance section; the delicate part was security. I moved login into a store that loads on demand, and the local flag that decides when to load it <strong>grants nothing</strong>: if someone forges it, all they get is to download Firebase Auth, because the real check is still the login and the admin document in Firestore.</p>
<h3>3. Vercel functions and ES modules</h3>
<p>The <code>api/</code> functions failed on Vercel: as native ES modules, relative imports need the <code>.js</code> extension. Besides the fix, I added a test that checks every import in <code>api/</code> so it doesn&#39;t happen again.</p>
<h3>4. A photo cleanup that can&#39;t get it wrong</h3>
<p>Deleting &quot;orphan&quot; photos is easy; deleting only the orphans, not so much. The cleanup learned with each case:</p>
<ul>
<li>If it can&#39;t read the sets, <strong>it deletes nothing</strong>. A failed read doesn&#39;t mean no photos are in use.</li>
<li>It only counts as deleted the photos Cloudinary <strong>confirms</strong>.</li>
<li>It respects the profile photo and the ones in the admin&#39;s corner, which live in the same folder.</li>
<li>It doesn&#39;t touch photos less than 7 days old and deletes at most 100 per run.</li>
</ul>
<h3>5. &quot;Saving…&quot; forever</h3>
<p>When saving the profile photo on a slow connection, if the save took too long the app asked the server whether it had arrived... with no time limit for that question. Now that check has its own 5-second limit, and reading the session saved in IndexedDB gives up after 2 seconds, because some browsers never answer.</p>
<hr>
<h2>📅 Day by day</h2>
<table>
<thead>
<tr>
<th>Day</th>
<th>Commits</th>
<th>What happened</th>
</tr>
</thead>
<tbody><tr>
<td>Oct 13</td>
<td>1</td>
<td>Project created: Angular, i18n and tooling.</td>
</tr>
<tr>
<td>Oct 14</td>
<td>43</td>
<td>Full prototype of the public site and photos from Cloudinary.</td>
</tr>
<tr>
<td>Oct 15</td>
<td>75</td>
<td>Firestore rules, login, signed uploads, editor, translation, themes, cleanup, 404 page. <strong>First release to production.</strong></td>
</tr>
<tr>
<td>Oct 16</td>
<td>3</td>
<td>Self-hosted fonts.</td>
</tr>
<tr>
<td>Oct 19</td>
<td>22</td>
<td>Public pages without the SDK, on-demand login, dashboard settings.</td>
</tr>
<tr>
<td>Oct 20</td>
<td>7</td>
<td>The About story and mobile tweaks.</td>
</tr>
<tr>
<td>Oct 21</td>
<td>23</td>
<td>Instagram icon, the admin&#39;s corner and the contact form.</td>
</tr>
<tr>
<td>Oct 22</td>
<td>3</td>
<td>Polishing the contact email.</td>
</tr>
<tr>
<td>Oct 26</td>
<td>12</td>
<td>Dashboard fitted to phones, show the password on sign-in and improvements to the admin&#39;s corner.</td>
</tr>
</tbody></table>
<hr>
<h2>🎯 Lessons</h2>
<ol>
<li><strong>Speed comes from the method, not just from AI.</strong> Written requirements, tests first, branches and review: without them, 75 commits in a day would be 75 problems.</li>
<li><strong>Persistent memory changes the game.</strong> An agent that remembers decisions and bugs from previous sessions doesn&#39;t repeat them.</li>
<li><strong>Verifying beats generating.</strong> Nothing reached production without passing tests, lint and build.</li>
<li><strong>Free tiers go a long way</strong> if you design around their limits (Cloudinary, Firebase, DeepL Free, Resend and Vercel).</li>
<li><strong>Security isn&#39;t delegated to the frontend.</strong> The Firestore rules and the functions that check for an admin are what protect it; the interface just goes along.</li>
</ol>
<blockquote>
<p>⭐ <strong>gentle-ai on GitHub:</strong> <a href="https://github.com/Gentleman-Programming/gentle-ai">github.com/Gentleman-Programming/gentle-ai</a>. If you find it useful, give it a star.</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 won&apos;t play on my Mac: five false leads and an unexpected culprit.</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>How a USB-C hub left my Mac&apos;s HDMI audio stuck, and why YouTube froze at 0:00 while I blamed the network for hours.</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_EN_dark.webp"/><enclosure length="82590" type="image/webp" url="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_EN_dark.webp"/><content:encoded><![CDATA[<p><img alt="YouTube won&apos;t play on my Mac: five false leads and an unexpected culprit." src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/infographic_EN_dark.webp"></p><h2>🎬 Introduction: the page loads, the video doesn&#39;t</h2>
<p>My company-managed MacBook had worked flawlessly for four years with this setup: a USB-C hub (Ethernet, webcam and a VGA output to a TV) and an external monitor over HDMI whose audio I use, with the monitor&#39;s headphone jack feeding the TV speakers. Then one day YouTube stopped playing: the page loaded perfectly, but the video spun forever at <strong>0:00</strong>.</p>
<p>This is what my desk looks like:</p>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/illustration_EN_dark.webp" alt="Illustration of my desk: MacBook Pro with a USB-C hub, a VS278 monitor over HDMI with stuck audio, and a TV as second screen"><p>The pattern was what made it baffling. It happened at home, but not at my second home, which uses a different ISP. And a Linux PC on the same home network, with the same browser, worked fine. Everything pointed to the <strong>network or the ISP</strong>, combined with a company-managed Mac (MDM, a SASE secure-web-gateway client, a managed encrypted DNS profile and an endpoint security agent). Too many suspects.</p>
<blockquote>
<p>💡 Spoiler: the network was innocent. But it took me hours to accept it, and the path is what&#39;s worth telling.</p>
</blockquote>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/layers_EN_dark.webp" alt="The layers we suspected and the one that actually failed"><hr>
<h2>🤖 How I worked: iterating with agents</h2>
<p>I didn&#39;t do this investigation alone. I worked in the terminal with AI coding agents (Claude Code) configured through <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>, an open-source tool (<a href="https://gentle-ai.gentlemanprogramming.com/">official website</a>) that configures the agents you already use with persistent memory, Organic-Driven Development, curated skills and sub-agent orchestration, with no agent lock-in.</p>
<p><strong>What gentle-ai brought in this case:</strong></p>
<ul>
<li><strong>Engram:</strong> persistent memory across sessions, so you never start from scratch.</li>
<li><strong>ODD (Organic-Driven Development):</strong> a lightweight flow, with a task document for substantial work, a branch before writing and no commits without my explicit permission.</li>
<li><strong>Sub-agent orchestration:</strong> the orchestrator delegated writers that drafted this post in three languages and generated the diagrams; I reviewed and corrected.</li>
</ul>
<p>gentle-ai was created by <a href="https://www.linkedin.com/in/alanbuscaglia/">Alan Buscaglia</a> (Gentleman Programming), and his rule for working with AI sums up this post well: <em>&quot;verifying beats generating&quot;</em>.</p>
<blockquote>
<p>⭐ <strong>gentle-ai on GitHub:</strong> <a href="https://github.com/Gentleman-Programming/gentle-ai">github.com/Gentleman-Programming/gentle-ai</a>. If you find it useful, give it a star: it helps more people find it.</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>The method was always the same, and we were both wrong at times: the agent also fell for wrong theories (the SASE client and ISP cache one was its own), and the evidence corrected it just as it corrected me.</p>
<p><strong>hypothesis → test → evidence → discard</strong></p>
<ul>
<li>🤖 <strong>The agent:</strong> read system and network logs, inspected configuration, captured and decoded the player&#39;s traffic, launched headless Chrome through DevTools, built the playback monitor, ran the isolation tests and wrote the Swift watcher with its LaunchAgent.</li>
<li>🙋 <strong>Me:</strong> provided context and screenshots, ran the browser checks the agent couldn&#39;t and, above all, noticed it broke exactly when plugging in the USB-C hub and confirmed the setup had worked the same way for four years.</li>
</ul>
<hr>
<h2>🕵️ False lead #1: the home DNS filter</h2>
<p>First the obvious one: the Pi-hole at home. We ruled it out before really starting, with evidence: DNS resolved everything correctly and only ad domains were blocked. Nothing to do with the video servers.</p>
<hr>
<h2>🚦 False lead #2: QUIC / HTTP3</h2>
<p>The classic &quot;page loads, video doesn&#39;t&quot;: a firewall or ISP breaking QUIC. We disabled QUIC in the browser (<code>brave://flags/#enable-quic</code>) and the video <strong>still failed</strong>. Ruled out.</p>
<hr>
<h2>🧩 False lead #3: ad blockers and extensions</h2>
<p>The console showed <code>net::ERR_BLOCKED_BY_CLIENT</code> errors for YouTube telemetry (<code>qoe</code>, <code>log_event</code>, <code>generate_204</code>). Very suspicious... until we tried a private window with shields off: telemetry went through and the video was still stuck. Those errors were <strong>noise</strong>.</p>
<hr>
<h2>🛡️ False lead #4: the SASE client</h2>
<p>This was the most convincing one. The SASE client&#39;s network extension kept running even when I &quot;disabled&quot; it from its UI (tamper protection), and its logs, which the agent read, showed it handling browser flows to <code>googlevideo.com</code>. On top of that, my ISP served the video from a Google cache node <strong>inside its own network</strong>. The hypothesis, which was the agent&#39;s own, fit beautifully: traffic tunneled through the SASE cloud gets rejected by the ISP-embedded cache.</p>
<p>Three facts took it apart:</p>
<ul>
<li>The certificate the browser received was <strong>Google&#39;s genuine one</strong>: no TLS interception.</li>
<li>The cache node answered fine (a proper <code>204</code>).</li>
<li>And decisively: <strong>Chrome doesn&#39;t go through that client at all</strong> (zero flows in its log) and failed identically.</li>
</ul>
<hr>
<h2>👻 False lead #5: the &quot;ghost&quot; IPv6 route</h2>
<p>Another AI assistant suggested an IPv6 problem. The agent checked, and then retracted the idea: the Mac had no global IPv6, the <code>utun</code> default routes were interface-scoped system tunnels, the browser&#39;s IPv6 attempt failed instantly and fell back to IPv4, and my home network has no IPv6 at all. Ruled out. Account and cookies weren&#39;t it either: a completely clean Chrome profile, with no cookies, no account and no extensions, stalled too.</p>
<hr>
<h2>🔬 The clue in plain sight</h2>
<p>All along there was one observation that, in hindsight, pointed away from the network. YouTube&#39;s response was <strong>valid</strong>: a SABR/UMP stream of about 1 MB of real audio and video, with stream protection status OK. The bytes arrived fast. But the player <strong>never consumed them</strong>: <code>video.readyState</code> stayed at 0, <code>buffered</code> was empty and the player sat in state 3 (buffering).</p>
<blockquote>
<p><em>&quot;The data arrives but the player never starts.&quot;</em> That is not a network problem.</p>
</blockquote>
<hr>
<h2>🔁 The turn: making it reproducible</h2>
<p>After a reboot it worked... and later it broke again. So I stopped guessing and asked the agent to build a <strong>monitor</strong>: every 2 minutes it launched a clean headless Chrome through the DevTools Protocol, loaded a video, checked <code>readyState</code> and <code>currentTime</code>, and snapshotted the system on every state change.</p>
<p>Then I noticed something, and this part was mine, not the agent&#39;s: <strong>it broke exactly when I plugged in the USB-C hub</strong>, on a setup I knew had worked the same way for four years. The agent ran an isolation test with the hub connected:</p>
<table>
<thead>
<tr>
<th>Audio output</th>
<th>Chrome with <code>--disable-audio-output</code></th>
<th>Chrome with audio</th>
</tr>
</thead>
<tbody><tr>
<td>MacBook speakers</td>
<td>OK</td>
<td>OK</td>
</tr>
<tr>
<td>HDMI monitor (VS278)</td>
<td>OK</td>
<td>FAIL (stuck at 0:00)</td>
</tr>
</tbody></table>
<p>There it was. Without audio the video plays; with audio going to the HDMI monitor, it doesn&#39;t.</p>
<hr>
<h2>🎯 The root cause: a stuck audio device</h2>
<p>Plugging the hub makes macOS reconfigure displays and audio devices, and the HDMI monitor&#39;s audio device ends up <strong>stuck</strong>, even though it never disappears from the device list. Chromium won&#39;t start playback until it can open the audio sink, so the video waits forever even though the data has already arrived.</p>
<p>Here is my setup, with the exact spot where it failed:</p>
<img class="theme-img theme-img--dark" src="https://rootdevs.es/assets/images/blog/youtubeHdmiAudio/setup_EN_dark.webp" alt="My setup: where the failure was"><p>This also explains the rest of the mystery:</p>
<ul>
<li>📍 <strong>&quot;It only happens at home&quot;</strong>: that&#39;s where the hub and monitor setup lives. The network was a red herring; the correlation with place was really a correlation with <strong>hardware</strong>.</li>
<li>⏱️ <strong>&quot;It breaks from time to time&quot;</strong>: the hub is a bit flaky and gets touched. Every re-plug triggers the problem again.</li>
</ul>
<hr>
<h2>🔧 The manual fix</h2>
<p>You just need to <strong>re-initialize the audio device</strong>. Three ways:</p>
<ol>
<li>Open <em>Audio MIDI Setup</em>, select the HDMI device and change the format from 48,000 Hz to 44,100 Hz and back.</li>
<li>Switch the sound output to another device and back.</li>
<li>Restart the audio service: <code>sudo killall coreaudiod</code>.</li>
</ol>
<p>A counter-test confirmed that the <strong>reset</strong> is what fixes it, not the sample rate: 48 kHz works again after a reset. Setting a fixed rate does <strong>not</strong> help, because macOS already remembers it per device.</p>
<hr>
<h2>🤖 The automated fix</h2>
<p>Since the problem came back with every re-plug, I asked the agent to automate it, and it wrote a small Swift program that uses CoreAudio and runs as a per-user LaunchAgent; I reviewed and installed it. It listens for changes to <code>kAudioHardwarePropertyDevices</code>, waits 4 seconds for things to settle (debounce), and toggles the target device&#39;s nominal sample rate to another available rate and back. The first version never triggered, because the monitor never disappears from the device list; the agent fixed it to react to any change in that list.</p>
<h3>🧾 The program (<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>📄 The LaunchAgent (<code>~/Library/LaunchAgents/local.hdmi-audio-reset.plist</code>)</h3>
<p>Replace <code>YOUR_USER</code> with your user name and <code>VS278</code> with the name of your audio device.</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>▶️ Compile and install</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>To uninstall:</p>
<pre><code class="language-bash">launchctl bootout gui/$(id -u)/local.hdmi-audio-reset
</code></pre>
<p>I verified it end to end: I unplugged and replugged the hub, the log showed <code>audio devices changed; resetting …</code>, then the reset, and YouTube played without touching anything.</p>
<hr>
<h2>🧠 Lessons</h2>
<ul>
<li><strong>Rule out layers with evidence, not intuition.</strong> Every false lead was closed with a concrete test, not a hunch.</li>
<li><strong>Distrust the first plausible story.</strong> The agent and I chased the network for hours because the story fit too well (the SASE and ISP cache theory was the agent&#39;s own).</li>
<li><strong>&quot;Data arrives but nothing plays&quot; means look at the media pipeline</strong> (audio, decoder), not the network.</li>
<li><strong>Make the failure reproducible and automated.</strong> A monitor every 2 minutes gave me what intuition couldn&#39;t: the trigger.</li>
<li><strong>Change one variable at a time.</strong> The isolation test in the table above solved in minutes what hours of hypotheses couldn&#39;t.</li>
<li><strong>The correlation with place was really a correlation with a hardware setup.</strong></li>
</ul>
<p>I did the investigation iterating with AI agents in the terminal (Claude Code, configured with <a href="https://github.com/Gentleman-Programming/gentle-ai">gentle-ai</a>): they ran and organized the tests, and fell for wrong theories just like I did. This post was also drafted by sub-agents orchestrated by gentle-ai (the text in three languages and the illustrations), and I reviewed and corrected it. What moved us both forward was ruling things out with evidence.</p>
<p>If you have a Mac, a USB-C hub and HDMI audio, and YouTube sits at 0:00, try resetting the audio device first. It might save you a whole afternoon. 🎧</p>
]]></content:encoded></item>
<item><title>Spotless Code in Multi-Vendor Teams.</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>How Prettier, Husky and lint-staged eliminate style wars in multi-vendor repositories without sacrificing Git history.</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_EN.webp"/><enclosure length="240974" type="image/webp" url="https://rootdevs.es/assets/images/blog/prettierPost/infographic_EN.webp"/><content:encoded><![CDATA[<p><img alt="Spotless Code in Multi-Vendor Teams." src="https://rootdevs.es/assets/images/blog/prettierPost/infographic_EN.webp"></p><h2>🌪️ Introduction: The Invisible Chaos of Spaces and Commas</h2>
<p>In large-scale projects where multiple consultancies coexist, the diversity of criteria is not only a cultural richness — it is also a technical risk. A problem as silent as it is disruptive emerges: <strong>visual inconsistency</strong>. When each team brings its own development DNA — some preferring tabs, others spaces; single quotes versus double quotes — the repository ceases to be a cohesive asset and becomes a battlefield of irrelevant changes.</p>
<p>This lack of unification is not a mere aesthetic detail; it is a source of operational friction and <em>vendor finger-pointing</em> during code reviews. Discussions about brace placement or line length <strong>steal golden minutes</strong> that should be invested in architecture or business logic.</p>
<blockquote>
<p>💡 For a technical leader, standardisation is not a whim: it is a strategy to eliminate noise and ensure that Code Reviews focus on what truly adds value.</p>
</blockquote>
<hr>
<h2>✨ Ending Style Wars with Prettier</h2>
<p>The solution to eradicate this friction is to delegate responsibility to <strong>Prettier</strong>. As an <em>opinionated</em> code formatter, Prettier eliminates subjectivity by parsing code and reprinting it under strict, predefined rules.</p>
<p>What makes it indispensable for a Software Architect is its <strong>safety</strong>: Prettier acts on the presentation layer without altering the Abstract Syntax Tree (AST). This provides a fundamental guarantee:</p>
<blockquote>
<p><em>&quot;The application&#39;s behaviour remains exactly the same&quot;</em></p>
</blockquote>
<p>Adopting Prettier is, in essence, an act of liberation for the team. By automating style, developers shed the cognitive load of visually &quot;fitting in&quot; with the rest, allowing <strong>Developer Experience (DevEx)</strong> to flow towards solving complex problems.</p>
<h3>⚙️ Minimal configuration in <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>🕵️ The Git &quot;Hack&quot; to Format Without Losing the Trail</h2>
<p>The greatest fear when implementing a standard on a live project is the <strong>mass commit</strong>. By formatting the entire codebase at once, we risk obscuring the original authorship. If a developer uses <code>git blame</code> to understand why a technical decision was made two years ago, they will find that the author is <em>&quot;the formatting bot&quot;</em>, losing historical traceability.</p>
<p>To evolve the codebase without sacrificing this trail, we use the <code>.git-blame-ignore-revs</code> file.</p>
<h3>🔍 How it works</h3>
<ol>
<li>You format the entire project and commit.</li>
<li>You obtain the hash of the formatting commit.</li>
<li>You register it in <code>.git-blame-ignore-revs</code>:</li>
</ol>
<pre><code class="language-bash"># Mass formatting commit with Prettier — 2024-10-15
a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
</code></pre>
<ol start="4">
<li>You configure Git to use it automatically:</li>
</ol>
<pre><code class="language-bash">git config blame.ignoreRevsFile .git-blame-ignore-revs
</code></pre>
<p>This functionality allows you to:</p>
<ul>
<li>👤 <strong>Preserve original authorship</strong>: Git ignores that commit in traces.</li>
<li>🏛️ <strong>Maintain history integrity</strong>: GitHub and GitLab respect this file, showing the true author of the business logic.</li>
<li>🔇 <strong>Evolution without noise</strong>: Achieve visual perfection without <em>&quot;contaminating&quot;</em> the historical responsibility of each line.</li>
</ul>
<hr>
<h2>🌐 Node.js as the Cross-Platform &quot;Source of Truth&quot;</h2>
<p>In modern ecosystems, especially in Angular-based projects, operating system independence is critical. The classic mistake of many automations is relying on <strong>Bash</strong> scripts, which often fail in Windows environments or require tedious manual configuration.</p>
<p>Our architecture proposes a paradigm shift: <strong>treating the repository as an application environment rather than a system environment</strong>. By centralising everything in Node.js, we guarantee true <em>cross-platform</em> behaviour:</p>
<ul>
<li>💻 <strong>Hardware agnosticism</strong>: The workflow works the same on a MacBook M3 as on a Windows 10 workstation.</li>
<li>🔒 <strong>Controlled environment</strong>: If the developer has Node.js installed (a requirement for Angular), the automation will work by default.</li>
<li>📦 <strong>Unified tooling</strong>: The <code>package.json</code> becomes the single source of truth.</li>
</ul>
<hr>
<h2>🐶 Husky and lint-staged: The Silent Guardians of the Repository</h2>
<p>For standardisation to be effective, it must be <strong>invisible and mandatory</strong>. We implement a forced compliance workflow using <strong>Husky</strong> and <strong>lint-staged</strong>, acting as a quality filter before code reaches the server:</p>
<ol>
<li>🚀 <strong>Trigger</strong>: The developer runs <code>git commit</code>.</li>
<li>🪝 <strong>Interception</strong>: Husky captures the event and delegates execution to Node.js.</li>
<li>🎯 <strong>Smart filtering</strong>: lint-staged identifies only the files in the <em>Staging Area</em>. No time is wasted processing thousands of static files — only what has changed.</li>
<li>✅ <strong>Formatting and validation</strong>: Prettier applies the format. If the code has critical syntax errors, the commit is aborted.</li>
</ol>
<h3>📦 Installation</h3>
<pre><code class="language-bash">npm install --save-dev husky lint-staged
npx husky init
</code></pre>
<h3>🔧 Configuration in <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>Pre-commit hook (<code>.husky/pre-commit</code>)</h3>
<pre><code class="language-bash">node_modules/.bin/lint-staged
</code></pre>
<blockquote>
<p>🛡️ This process eliminates the <strong>human forgetfulness factor</strong> and guarantees that no non-standard code ever enters the repository, without the developer having to run a single additional command.</p>
</blockquote>
<hr>
<h2>📋 Action Plan: Proof of Concept (POC)</h2>
<p>As Architects, we do not implement critical changes blindly. The path to visual excellence follows a clear technical roadmap:</p>
<ol>
<li>🌿 <strong>Isolation</strong>: Create a <code>feature/prettier-standardisation</code> branch to avoid disrupting the current workflow.</li>
<li>⚙️ <strong>Configuration</strong>: Install the stack (Prettier, Husky, lint-staged) and define the rules in <code>.prettierrc</code>.</li>
<li>💥 <strong>Mass execution</strong>: Format the entire project and generate the reference commit.</li>
<li>🛡️ <strong>History protection</strong>: Configure the <code>.git-blame-ignore-revs</code> file with the obtained hash.</li>
<li>🎬 <strong>Live demo</strong>: Show the team and client how poorly indented code on Windows is instantly corrected on commit, preserving the original authorship in the history.</li>
</ol>
<hr>
<h2>🚀 Conclusion: Towards a Culture of Clean Code</h2>
<p>Adopting this ecosystem is not just a technical improvement; it is a <strong>declaration of principles</strong> about quality. The benefits are immediate: more agile code reviews, an impeccable Git history, and the complete elimination of aesthetic friction between consultancies.</p>
<p>The investment to implement this architecture is minimal compared to the long-term savings in time and frustration. At the end of the day, the question for any technology leader is simple:</p>
<blockquote>
<p>How much time is your team wasting discussing the format of commas, when they could be strengthening the architecture of their system?</p>
</blockquote>
<p>Automation is the bridge between visual chaos and technical excellence. And happy May the 4th, may the force be with you in every commit! 🌌⚔️</p>
]]></content:encoded></item>
</channel>
</rss>
