<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://carloslb.com/en/feed.xml" rel="self" type="application/atom+xml" /><link href="https://carloslb.com/en/" rel="alternate" type="text/html" /><updated>2026-08-03T05:35:22+00:00</updated><id>https://carloslb.com/feed.xml</id><title type="html">Carlos León Bolaños</title><subtitle>Perfil profesional - IT, Administración y Formación</subtitle><entry xml:lang="en"><title type="html">How this site was built (3): the things that never throw an error</title><link href="https://carloslb.com/en/blog/2026/08/02/como-se-hizo-esta-web-3-lo-que-no-da-error/" rel="alternate" type="text/html" title="How this site was built (3): the things that never throw an error" /><published>2026-08-02T13:00:00+00:00</published><updated>2026-08-02T13:00:00+00:00</updated><id>https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-3-lo-que-no-da-error.en</id><content type="html" xml:base="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-3-lo-que-no-da-error/"><![CDATA[<p>If building this site taught me one thing, it's this: <strong>the enemy isn't the error, it's the silence.</strong> An error tells you where to look. A silent failure leaves you staring at a page that looks fine for days. This third part is about everything that broke without complaining, and how you catch it.</p>

<p>Before this come <a href="/en/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/">part one</a>, on architecture, and <a href="/en/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo/">part two</a>, on privacy and regulation.</p>

<h2 id="five-failures-that-gave-no-warning-at-all">Five failures that gave no warning at all</h2>

<p><strong>Liquid doesn't throw errors: it returns nothing.</strong> Jekyll's template language, faced with a mistyped key, doesn't complain: it returns emptiness. The result is a link with an empty destination, or a blank label. The page builds, ships and is broken. I discovered that an identifier behaved differently with and without quotation marks — without them it was treated as a non-existent variable — by watching an illustration vanish with no explanation whatsoever.</p>

<p><strong>YAML is case sensitive.</strong> I wrote <code class="language-plaintext highlighter-rouge">jobtitle</code> in a data file and <code class="language-plaintext highlighter-rouge">jobTitle</code> in the template. The result was a null value in the page's structured data. Perfectly valid JSON, data lost, zero warnings.</p>

<p><strong>The filter that was computed and never used.</strong> In the blog listing, the per-profile filter was correctly stored in a variable… and the loop went on iterating over the full collection. The IT blog was also showing Administration articles. Worse still, it was <strong>camouflaged</strong>, because the profile with no articles did show the "nothing here yet" message and the others "showed articles". Everything looked like it worked.</p>

<blockquote>
  <p><strong>Method lesson: check <em>what</em> comes out, not just <em>whether</em> something comes out.</strong></p>
</blockquote>

<p><strong>The skip link with no styles.</strong> From day one the site had a "Skip to content" link for keyboard users. The markup was there… and it never had any CSS. For weeks it had been appearing as loose text in the corner of every page and I read straight past it.</p>

<p><strong>The focus that didn't move.</strong> And once I finally styled it, it still didn't work properly: pressing it scrolled the page down to the content, but keyboard focus stayed at the top. The next tab press sent you back into the header. It looked like it worked. The fix was to let the main element receive focus programmatically.</p>

<p>That last one deserves an explanation, because it's the perfect illustration of why it exists:</p>

<pre><code class="language-mermaid">flowchart LR
  accTitle: Keyboard journey with and without a skip link
  accDescr {
    Without a skip link, a keyboard user has to press tab around twelve times to get through
    the whole header before reaching the content, and must do so on every page they visit.
    With a skip link, the very first tab press offers to jump straight to the content on the
    second press.
  }
  subgraph WITHOUT["Without a skip link"]
    direction LR
    A1["Tab 1"] --&gt; A2["..."] --&gt; A3["Tab 12"] --&gt; A4["Content"]
  end
  subgraph WITH["With a skip link"]
    direction LR
    B1["Tab 1:&lt;br/&gt;Skip to content"] --&gt; B2["Content"]
  end
</code></pre>

<p>The header has around twelve links. Without a skip link, someone navigating by keyboard goes through all of them <strong>on every single page they visit</strong>. That's success criterion 2.4.1, Bypass Blocks, and it's level A: the bare minimum.</p>

<p>The styling has a nice technical twist. A single focus ring will always fail against some background, and there are eight palettes here. The solution was a <strong>double concentric ring</strong>: a light outline with a dark shadow around it. The outline paints on top of the shadow, so one of the two will contrast against any possible background. And it sits above the sticky header, which also covers criterion 2.4.11, Focus Not Obscured, new in version 2.2.</p>

<h2 id="the-audit-twenty-reports-and-what-none-of-them-catches">The audit: twenty reports, and what none of them catches</h2>

<p>The target was <strong>WCAG 2.2, level AA</strong>. I did the automated part with axe DevTools against the live site: twenty reports, covering all eight palettes, with the full battery of guided tests — keyboard, forms, structure, images, tables, dialogs, interactive elements. Final result: zero issues.</p>

<p>What had to be fixed along the way:</p>

<table>
  <thead>
    <tr>
      <th>Finding</th>
      <th>Fix</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Missing top-level heading on three profiles and on the contact page</td>
      <td>Added in both language versions</td>
    </tr>
    <tr>
      <td>Required fields not explicitly marked for assistive technology</td>
      <td>Declared explicitly</td>
    </tr>
    <tr>
      <td>Skip link with no focus indicator (critical)</td>
      <td>The double ring described above</td>
    </tr>
  </tbody>
</table>

<p>But something that often goes unsaid is worth saying: <strong>automated tools catch roughly a third of real problems.</strong> The rest has to be seen by a person. That meant:</p>

<ul>
  <li><strong>Reflow at 320 pixels wide</strong> across fourteen pages, with no horizontal scrolling and no loss of content.</li>
  <li><strong>Text-only zoom to 200%.</strong> This uncovered a fault no tool would have caught: as the text grew, so did the sticky header, and the scroll offset reserved for it fell short. Anchors were leaving the heading half hidden.</li>
  <li><strong>Forced text spacing</strong>, in case someone applies their own stylesheet.</li>
  <li><strong>A full keyboard pass</strong>: reachability, visible focus, logical order, no traps.</li>
  <li><strong>The NVDA screen reader</strong>, the most widely used in Europe.</li>
</ul>

<p>And one criterion <strong>no tool on earth can check</strong>: 3.1.2, Language of Parts. When I leave a Spanish expression untranslated in an English text — say <span lang="es">chácaras y tambor</span>, the instruments of a Canary Islands folk group — it has to be marked up as such, or the screen reader will pronounce it using the rules of the wrong language and the braille display will apply contractions that don't belong. No machine knows that an unmarked phrase is in another language. Only someone reading it does. (Yes, that phrase above is marked up. Go and look at the source.)</p>

<p>That's the work still outstanding, and it's why this site's accessibility statement isn't published yet: I'd rather write it when I can say exactly what I've measured.</p>

<h2 id="two-details-i-learned-from">Two details I learned from</h2>

<p><strong>Reduced motion.</strong> The site honours the system preference for reduced motion. The interesting part is how: the duration is set to one hundredth of a millisecond, <strong>not to zero</strong>. At zero, some animations never fire at all, and a script waiting for a "transition ended" event waits forever. It's also one of the few places where marking a rule as important is justified: it implements the user's intent, not the developer's.</p>

<p><strong>The client is never a security boundary.</strong> Character limits, required fields and the form's character counter are convenience, not defence. Anyone who can edit the HTML can edit the JavaScript. The real defence always lives on the server.</p>

<h2 id="publishing">Publishing</h2>

<p>The native GitHub Pages builder was no use to me: it's pinned to a much older version of Jekyll and supports neither the bilingual plugin nor modern Sass. That wasn't a preference, it was a constraint: a custom workflow was the only way.</p>

<pre><code class="language-mermaid">flowchart LR
  accTitle: Automated publishing pipeline for the site
  accDescr {
    Pushing changes to the repository's main branch starts a GitHub Actions run that sets up
    Ruby and installs the dependencies, builds the site in production mode, packages the
    result and deploys it to GitHub Pages, which serves it under the custom domain.
  }
  A["Push to the main branch"] --&gt; B["GitHub Actions"]
  B --&gt; C["Ruby 3.3&lt;br/&gt;and dependency install"]
  C --&gt; D["Build&lt;br/&gt;in production mode"]
  D --&gt; E["Package the site"]
  E --&gt; F["Deploy to GitHub Pages"]
  F --&gt; G["carloslb.com"]
</code></pre>

<p>Three things I learned there:</p>

<p><strong>URL and base path are not the same thing.</strong> One key is protocol plus domain; the other is the subfolder within that domain, and it only takes a value if the site doesn't live at the root. Putting the domain in the second one would duplicate the domain in every generated link and break absolutely everything. Before going live, that key was still pointing at my virtual machine's private IP: had I left it, the sitemap and the canonical URLs would have shipped pointing at an address on my local network.</p>

<p><strong>Intermittent failures are the worst kind.</strong> The bilingual plugin has a documented race condition on continuous integration runners: one process writes into a language directory another hasn't created yet. It fails sometimes. You turn parallelisation off and that's that.</p>

<p><strong>The last hurdle was daft and highly instructive.</strong> The push was rejected because of the commit's email address. I had fixed the Git configuration, but <strong>configuration only affects future commits</strong>: the one already made kept the old author. You fix it by rewriting the authorship of the pending commit — harmless as long as nothing has been pushed yet.</p>

<h2 id="what-i-take-away">What I take away</h2>

<ol>
  <li><strong>The silent failure is the enemy.</strong> What isn't complaining isn't fine: it's quiet.</li>
  <li><strong>Magic numbers lie.</strong> When a number describes something that can change — an element's width, a header's height — sooner or later it stops being true.</li>
  <li><strong>Cohesion prevents errors that discipline doesn't.</strong> Two tags whose relative order matters belong together in one place, not "correctly positioned" separately.</li>
  <li><strong>The output folder doesn't always empty itself.</strong> Faced with anything odd: stop, empty, start again, and <em>then</em> diagnose. I lost an afternoon chasing a ghost path left over from an earlier build.</li>
  <li><strong>Check the source before asserting.</strong></li>
</ol>

<p>That last one deserves to close the series, because it ties back to what I said in part one about working with an AI assistant. It got things wrong three times in the same way: asserting with confidence something inferred from an incomplete source — a truncated file listing, an out-of-date copy of the project, a filename cut short by length. On one of those occasions it even corrected me when I was right.</p>

<p>I caught all three because I know my own project and because something didn't sit right. And that's exactly the same lesson as the rest of the article: <strong>a confident, wrong answer is a silent failure too.</strong> It throws no error. It sounds good. And it needs checking just like a loop that "shows articles".</p>

<p>Whoever gives you an answer, go and look at the source.</p>

<hr />

<p>The code for all of this is at <a href="https://github.com/carloslb-com/carloslb-com.github.io">github.com/carloslb-com/carloslb-com.github.io</a>. If anything here is useful to you, take it. And if you run into an accessibility barrier on this site, <a href="/en/contact/">get in touch</a>: I'd rather know than be right.</p>]]></content><author><name></name></author><category term="it" /><category term="Jekyll" /><category term="web accessibility" /><category term="WCAG 2.2" /><category term="NVDA" /><category term="progressive enhancement" /><category term="GitHub Actions" /><category term="GitHub Pages" /><category term="axe DevTools" /><summary type="html"><![CDATA[Almost nothing that broke in this project threw an error. A tour of the silent failures, the accessibility audit and getting the site published.]]></summary></entry><entry xml:lang="en"><title type="html">How this site was built (2): privacy by default and paperwork without fiction</title><link href="https://carloslb.com/en/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo/" rel="alternate" type="text/html" title="How this site was built (2): privacy by default and paperwork without fiction" /><published>2026-08-02T11:00:00+00:00</published><updated>2026-08-02T11:00:00+00:00</updated><id>https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo.en</id><content type="html" xml:base="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-2-privacidad-y-papeleo/"><![CDATA[<p>A personal website looks harmless. And yet every typeface served from someone else's service, every library pulled from a content delivery network and every share button is a third party receiving your visitor's IP address, without that person having asked for it or even knowing. This part is about closing those doors, and about the paperwork that follows.</p>

<p>In <a href="/en/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/">part one</a> I covered the architecture. Here comes the part you can't see.</p>

<h2 id="the-diagram-that-gave-me-away">The diagram that gave me away</h2>

<p>I was going to write articles with diagrams, so I set up Mermaid. It worked first time: one line pointing at a public delivery network and done.</p>

<p>I spotted the problem while rereading the cookie policy I had just written. That line lived in the base template, which is to say <strong>on every page of the site</strong>. Every visitor, whichever door they came through, was handing their IP address to an outside company in order to load a library that was only needed on articles with a diagram. In other words: none. I was declaring one thing and doing another.</p>

<p>Self-hosting it cost more than it looked:</p>

<ul>
  <li>The modern bundle uses <strong>code splitting</strong>: the main file imports dozens of chunks from a sibling folder. Copying the single file breaks those imports — which is exactly why an earlier attempt kept "missing files". The fix was to use the classic single-file build.</li>
  <li><strong>GitHub doesn't publish compiled builds.</strong> The repository holds the source code; the distribution folder lives in the package registry, and that's where the public networks copy it from. You have to go and fetch it from the right place.</li>
  <li>A JavaScript trap that cost me a while: an <code class="language-plaintext highlighter-rouge">import</code> inside a script that isn't declared as a module throws a syntax error that <strong>kills the entire script</strong>. It isn't that line that fails: nothing runs at all.</li>
</ul>

<p>The library now lives on the site itself and loads <strong>only on pages that declare they contain a diagram</strong>. I did the same with the maths formulas, with an extra trap: in the current version the fonts don't ship with the package and the loader points to an external network by default. If you don't override that, you've self-hosted the engine and you're still calling out.</p>

<p>The result: <strong>this site doesn't load a single third-party resource.</strong> Typefaces, icons, diagram and formula engines: all served from here.</p>

<h2 id="no-cookies-and-that-isnt-a-marketing-line">No cookies, and that isn't a marketing line</h2>

<p>There are no cookies. No analytics, no advertising, no session. So there's no consent banner either, because there would be nothing to consent to.</p>

<p>The only thing stored in your browser is your theme preference, light or dark, in local storage. According to the cookie guidance issued by the <span lang="es">Agencia Española de Protección de Datos</span> (the Spanish data protection authority), that counts as technical storage for personalisation <strong>at the user's request</strong>, and is exempt from consent. The difference isn't the mechanism: it's what you use it for.</p>

<h2 id="the-form-sending-a-message-without-a-server-of-your-own">The form: sending a message without a server of your own</h2>

<p>A static site can't process a form. You need a third party. I compared fourteen services against three criteria: that the data be processed within the European Union, that there be a proper data processing agreement, and that the free tier actually be usable.</p>

<pre><code class="language-mermaid">sequenceDiagram
  accTitle: The journey of a message sent through the contact form
  accDescr {
    The visitor fills in the form on the site and submits it with an ordinary HTTP request,
    without the site needing any JavaScript of its own. The provider, whose servers are in
    Germany, applies its spam filter and honeypot field, then responds with a verification
    page of its own. There the browser solves a cryptographic challenge by itself and the
    person is only asked to confirm with a click. The provider then delivers the message to
    the mailbox and redirects the visitor to the site's thank-you page.
  }
  participant V as Visitor
  participant W as carloslb.com
  participant F as Provider (Germany)
  participant M as Mailbox
  V-&gt;&gt;W: fills in and submits the form
  W-&gt;&gt;F: ordinary HTTP request, no JavaScript of its own
  F-&gt;&gt;F: spam filter and honeypot field
  F--&gt;&gt;V: its own verification page
  Note over V,F: The browser solves the challenge.&lt;br /&gt;the person is only asked for a click
  V-&gt;&gt;F: confirms with a click
  F-&gt;&gt;M: delivers the message
  F--&gt;&gt;V: redirects to the thank-you page
  Note over V,F: None of the provider's resources are embedded in the site
</code></pre>

<p>That detail about the captcha is the key one: <strong>it is served on the provider's own page, after you press send.</strong> Nothing of theirs is embedded in my site, and the form submits with plain HTML, without any JavaScript of its own.</p>

<p>And the kind of captcha matters as much as where it lives. There's no distorted text to decipher and no traffic lights to point at: the browser solves a cryptographic challenge by itself, and the person is only asked to confirm with a click. That isn't merely convenience, it's accessibility. Image-based captchas are a well-known barrier for people with low vision or those using a screen reader; when the device does the work instead of the person, there's no visual or audio test left to pass.</p>

<p>I ruled out one competitor for processing data in the United States and using cookies, and another — open source and rather elegant — because it required a back end of my own that I don't have.</p>

<p>Two more decisions about the form:</p>

<p><strong>Data minimisation.</strong> Name, email, subject and message. No phone number, no line of business. If I don't need it in order to reply to you, I don't ask for it. That's what the regulation requires, but it's also common sense: data you don't hold can't leak.</p>

<p><strong>Disabling the submit button until the form is valid: ruled out on accessibility grounds.</strong> It's a widespread pattern and it's a bad one. A disabled button doesn't explain why it's disabled, and since it can't receive keyboard focus, a screen reader won't even announce it. The person is left facing a form that doesn't respond, with no clue as to why. The browser's native validation already warns them and moves focus to the offending field. Less code and more accessible.</p>

<h2 id="which-regulations-actually-apply">Which regulations actually apply</h2>

<p>This is where my other trade comes in. Before copying a legal template, I worked out what does and doesn't bind me. <em>This is my reading of my own case, not legal advice: if your situation differs, so does the outcome.</em></p>

<pre><code class="language-mermaid">flowchart TD
  accTitle: Which regulations apply to a personal website with no economic activity
  accDescr {
    Four checks. Royal Decree 1112 of 2018 on accessibility only covers the public sector,
    so it does not apply. Law 11 of 2023 does not apply because there is no e-commerce. The
    Spanish information society services act does not apply because there is no economic
    activity, which means neither a legal notice nor a cookie policy is mandatory. The
    General Data Protection Regulation, on the other hand, does apply, because the contact
    form collects personal data: a privacy policy is mandatory.
  }
  A["Is this a public sector site?"] --&gt;|No| A2["Royal Decree 1112/2018:&lt;br/&gt;does not apply"]
  B["Is there any e-commerce?"] --&gt;|No| B2["Law 11/2023:&lt;br/&gt;does not apply"]
  C["Is there any economic activity?"] --&gt;|No| C2["LSSI-CE: does not apply&lt;br/&gt;Legal notice and cookie policy&lt;br/&gt;are not mandatory"]
  D["Is personal data collected?"] --&gt;|"Yes: the form"| D2["GDPR: applies&lt;br/&gt;Privacy policy mandatory"]
</code></pre>

<p>The result: of the four documents everyone copies, <strong>only one was actually required</strong>. I keep the others by choice — I'd rather err on the side of too much — but with one rule:</p>

<blockquote>
  <p><strong>Keeping the document is not the same as keeping the fiction.</strong></p>
</blockquote>

<p>Legal templates come bloated. The one I downloaded declared processing for commercial, statistical and browsing-analysis purposes. None of that exists here. And declaring processing that doesn't exist does harm twice over: it misinforms the reader, and it commits you to legal bases you don't have. So I pruned everything that wasn't true.</p>

<p>The same goes for the data I publish about myself: name and email address. No national identity document, no home address, no phone number. The template asked for them; the law, in my case, does not. For consistency, the repository commits use GitHub's no-reply forwarding address, and there is no email address in the pages' structured data: address harvesters crawl precisely there.</p>

<p>You can read the outcome in the <a href="/en/privacy.html">privacy policy</a> and the <a href="/en/cookiespolicy.html">cookie policy</a>. I rewrote the latter from scratch, because a template that opens by explaining which cookies it uses is no use for saying that you use none.</p>

<hr />

<p><strong>Next:</strong> in part three, what nearly broke the site without throwing a single error, how you actually audit accessibility, and why the GitHub Pages builder wasn't an option for me.</p>]]></content><author><name></name></author><category term="it" /><category term="admin" /><category term="Jekyll" /><category term="GDPR" /><category term="data protection" /><category term="privacy" /><category term="contact form" /><category term="cookies" /><category term="self-hosting" /><category term="compliance" /><summary type="html"><![CDATA[Zero third-party resources, a contact form that works without JavaScript, and an honest look at which regulations actually apply to a personal website.]]></summary></entry><entry xml:lang="en"><title type="html">How this site was built (1): one person, three trades, one website</title><link href="https://carloslb.com/en/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/" rel="alternate" type="text/html" title="How this site was built (1): one person, three trades, one website" /><published>2026-08-02T09:00:00+00:00</published><updated>2026-08-02T09:00:00+00:00</updated><id>https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos.en</id><content type="html" xml:base="https://carloslb.com/blog/2026/08/02/como-se-hizo-esta-web-1-tres-mundos/"><![CDATA[<p>I have three trades and one face. I work with systems and networks, I handle bookkeeping and paperwork, and I teach. A portfolio covering only one of the three would lie by omission; three separate portfolios would mean three maintenance jobs and none of them up to date. This series is about how I got out of that, what I decided and — above all — what I ruled out and why.</p>

<p>I started in early June 2026. The site went live on 22 July. In between there are a fair few decisions you can't see, and those are exactly the ones worth telling: <strong>the value of a portfolio isn't in the list of technologies, it's in the reasoned trade-offs.</strong></p>

<p>All the code is out in the open: <a href="https://github.com/carloslb-com/carloslb-com.github.io">github.com/carloslb-com/carloslb-com.github.io</a>.</p>

<h2 id="the-principle-that-ordered-everything-else">The principle that ordered everything else</h2>

<p>Before writing a single line I set myself a limit: <strong>have something to show, rather than being 99.9% accurate.</strong> A perfect portfolio that never ships isn't a portfolio, it's a folder. And a working motto: <strong>robustness over brevity or cleverness.</strong> Every time I hesitated between the clever solution and the boring one that holds up, the boring one won.</p>

<h2 id="the-stack-and-why">The stack, and why</h2>

<table>
  <thead>
    <tr>
      <th>Piece</th>
      <th>Choice</th>
      <th>Reason</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Generator</td>
      <td>Jekyll 4.4.1 (Ruby 3.3.8)</td>
      <td>Static site: no database and no admin panel to attack</td>
    </tr>
    <tr>
      <td>Styles</td>
      <td>Dart Sass with <code class="language-plaintext highlighter-rouge">@use</code> / <code class="language-plaintext highlighter-rouge">@forward</code></td>
      <td>Real modules; <code class="language-plaintext highlighter-rouge">@import</code> is deprecated</td>
    </tr>
    <tr>
      <td>Bilingual</td>
      <td>jekyll-polyglot</td>
      <td>Two trees (<code class="language-plaintext highlighter-rouge">/</code> and <code class="language-plaintext highlighter-rouge">/en/</code>) without duplicating templates</td>
    </tr>
    <tr>
      <td>Extras</td>
      <td>jekyll-feed, jekyll-seo-tag, jekyll-sitemap</td>
      <td>The standard set, nothing invented</td>
    </tr>
    <tr>
      <td>Environment</td>
      <td>Ubuntu virtual machine on VirtualBox</td>
      <td>Development isolated from my working computer</td>
    </tr>
    <tr>
      <td>Publishing</td>
      <td>GitHub Pages with a custom GitHub Actions workflow</td>
      <td>Out of necessity, not preference: more on that in part three</td>
    </tr>
  </tbody>
</table>

<p>The important thing about that table is what <strong>isn't</strong> there: no database, no CMS, no third-party services embedded anywhere. A static site is a pile of HTML files. There's no session to steal and no query to inject.</p>

<h2 id="one-person-three-worlds">One person, three worlds</h2>

<p>The structure is identical across the three profiles. Header, sections, blog, footer: the same. The only thing that changes is the <strong>skin</strong>: the palette and the typeface.</p>

<pre><code class="language-mermaid">flowchart TD
  accTitle: Site structure, with a neutral landing page and three profiles
  accDescr {
    The landing page is a neutral space with three doors: IT and Telecommunications,
    Administration and Human Resources, and Training and Teaching. All three lead to the
    same page structure, and the only thing that differs between them are the CSS tokens
    that define the colour palette and the typeface.
  }
  H["Neutral landing page&lt;br/&gt;(three doors)"] --&gt; IT["IT / Telecommunications"]
  H --&gt; AD["Administration / HR"]
  H --&gt; FO["Training / Teaching"]
  IT --&gt; B["Same structure:&lt;br/&gt;header, sections, blog, footer"]
  AD --&gt; B
  FO --&gt; B
  B --&gt; T["Only the CSS tokens change:&lt;br/&gt;palette and typeface"]
</code></pre>

<p>That's <strong>eight palettes</strong>: four worlds (the three profiles plus the neutral landing page) times two themes, light and dark. I validated them all at high contrast — most of them at AAA level — <em>before</em> writing a single line of content. Fixing contrast at the end means redoing everything.</p>

<p>The typefaces are self-hosted as <code class="language-plaintext highlighter-rouge">woff2</code>. The body text is <strong>Atkinson Hyperlegible</strong>, designed specifically for low vision. Headings change by world: JetBrains Mono for IT, IBM Plex Serif for Administration, Nunito for Training. A typeface says a great deal before the first word is read.</p>

<p>The data — projects, certifications, courses, work history — lives in YAML, following a <code class="language-plaintext highlighter-rouge">key → {es, en}</code> pattern. Only what actually changes between languages gets translated. A year is a year in both.</p>

<h3 id="a-small-decision-with-large-consequences">A small decision with large consequences</h3>

<p>The profile class lives on the <code class="language-plaintext highlighter-rouge">html</code> element, not on <code class="language-plaintext highlighter-rouge">body</code>, alongside the theme attribute. If it lives on <code class="language-plaintext highlighter-rouge">body</code>, the browser has already started painting by the time you apply the theme, and you get a flash of the wrong palette. With the class at the top, the script runs before there's anything to flash.</p>

<p>Side effect: because both attributes hang off the same element, the palette selectors are concatenated <strong>without a space</strong>. Yes, CSS has its own small print too.</p>

<h2 id="colour-can-depend-on-javascript-navigation-cannot">Colour can depend on JavaScript; navigation cannot</h2>

<p>This is the decision I'm proudest of, and the one that ruled out the most.</p>

<p>The requirement was: if you enter the blog through the IT door, the article should <em>feel</em> like IT. But an article is a single file, and it can be reached from three places and in two languages.</p>

<p><strong>Path 1: generate physical versions of the article per context.</strong> Three contexts times two languages is six versions of every article. Unworkable maintenance and duplicate URLs competing with each other. Ruled out.</p>

<p><strong>Path 2: have JavaScript rebuild the navigation bar on the fly.</strong> More code, more fragile, and without JavaScript the visitor can't navigate at all. Ruled out.</p>

<p><strong>What I did:</strong> the colour and the typefaces do change according to the door you came through; the navigation bar stays put.</p>

<pre><code class="language-mermaid">sequenceDiagram
  accTitle: How an article takes on the colour of the door you came through
  accDescr {
    From the IT blog listing, the link to the article carries a parameter indicating where
    the visitor came from. The article is served with the neutral skin. A script placed in
    the document head reads that parameter before the browser paints anything, applies the
    matching profile class and removes the parameter from the address bar. The navigation
    bar never changes.
  }
  participant L as IT blog listing
  participant N as Browser
  participant A as Article
  L-&gt;&gt;N: link carrying the origin parameter
  N-&gt;&gt;A: requests the article (served with the neutral skin)
  A-&gt;&gt;A: a script in the document head reads the parameter before painting
  A-&gt;&gt;N: applies the IT skin and cleans up the address
  Note over N,A: The navigation bar never changes
</code></pre>

<p>If the script fails, the article shows up in neutral colours and reads perfectly. Nobody is locked out.</p>

<p>Two takeaways from that, which I've already reused elsewhere:</p>

<blockquote>
  <p><strong>When every solution to a requirement is a bad one, sometimes the problem is the requirement, not the solution.</strong></p>
</blockquote>

<blockquote>
  <p><strong>Look for the version that delivers most of the perceived value for a fraction of the cost.</strong> Colour gives you 90% of the sense of being in a different world. A dynamic navigation bar added 10% at enormous cost.</p>
</blockquote>

<h2 id="how-i-worked-on-this">How I worked on this</h2>

<p>I didn't do it alone. I used an AI assistant as a tool: for reference, for learning Jekyll and Liquid, for diagnosis when something didn't add up, and for organising and tracking the tasks of a project that ran for two months and has a lot of moving parts.</p>

<p>I'm saying so plainly, because hiding it would be the exact opposite of what I stand for. And because the distinction matters: the tool speeds up research and keeps the work organised, but it doesn't make the decisions. The ones above — what to rule out, which requirement to question, where to stop — are mine, and each of them has its reasoning written down. In part three I'll tell you where it got things wrong and how I caught it.</p>

<hr />

<p><strong>Next:</strong> in part two, why this site doesn't load a single third-party resource, how the contact form works without JavaScript, and which regulations actually apply to a personal website — considerably fewer than the templates would have you believe.</p>]]></content><author><name></name></author><category term="it" /><category term="Jekyll" /><category term="Liquid" /><category term="static site" /><category term="Sass" /><category term="bilingual site" /><category term="web architecture" /><category term="portfolio" /><summary type="html"><![CDATA[Why a hybrid profile doesn't fit into a single-subject portfolio, and how three worlds can share one structure and only change their skin.]]></summary></entry></feed>