<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
  <title>Enea Log</title>
  <link>https://eneawork.it/blog</link>
  <description>Research, development, and systems exploration by Enea.</description>
  <language>en-us</language>
  <lastBuildDate>Tue, 06 Oct 2026 02:09:46 GMT</lastBuildDate>
    <item>
    <title>why does every website i see suck?</title>
    <link>https://eneawork.it/blog/why-every-website-i-see-sucks</link>
    <description><![CDATA[in italy almost everything is a wordpress site, and a lot of it is genuinely bad. but the version of this complaint that turns into "italians cannot design" is wrong, and i think it is worth separating the two...]]></description>
    <content:encoded><![CDATA[<p>this is not a post about wordpress. i want to be clear about that before i start, because the version of this complaint that circulates in italian design twitter is always really a complaint about one tool, and the tool is not the problem.</p>
<p>the claim is roughly this: <strong>in italy everything is a cms, everything made with a cms looks the same, therefore italian web design is bad.</strong> i have heard it said about small businesses, about restaurants, about dentists, about lawyers. i have heard it said with a lot of enjoyment, which should already be a warning sign.</p>
<h2>the honest version of the complaint</h2>
<p>some of it is completely fair. if you open thirty sites for italian plumbers and twenty of them are the same template with a different photograph, you have learned something real about the market. that is not a taste problem, that is a supply problem: there is a template, there is a person who installs it for four hundred euros, and there is nobody between the two of you.</p>
<p>what is actually true:</p>
<ul>
<li>most small-business sites are template installations, not design work</li>
<li>the template is chosen by whoever is cheapest to find, not by whoever understands the reader</li>
<li>the result is a page that ranks, converts a little, and is forgotten</li>
<li>a large amount of it is slow, and a large amount of it breaks on a phone</li>
</ul>
<p>none of that requires wordpress to be true. it requires the buyer to not know what they are buying and the seller to not tell them.</p>
<h2>the version that is wrong</h2>
<p>the wrong version says the cms is to blame. the cms is a tool. wordpress is a tool that a great many people, including a lot of very good developers, use well.</p>
<p>the more useful claim is: <strong>the tool is chosen by the person with the least power in the transaction.</strong> the person paying for a site is not the person operating it. the operator wants something they can update without calling anyone. that is a completely rational want, and it is the want a cms is built to satisfy.</p>
<p>so the cms is the symptom. the cause is that the person paying and the person using the site are different people, and nobody in the middle is responsible for how it reads.</p>
<h2>the part i actually object to</h2>
<p>what makes me angry is not the template. it is that these sites are then sold as <strong>design</strong>.</p>
<p>a small business gets told it is getting a website. what arrives is a page with a hero image, three feature boxes with icons in them, a contact form that goes nowhere, and a claim that it is "modern". the price is serious money. the same shop could have had something readable, fast, and honest for a fraction of it, and did not know to ask.</p>
<p>that is not a taste disagreement. <strong>it is someone being paid for the appearance of the work rather than the work</strong>, and the buyer has no way to tell the difference because they have no reference for what good looks like.</p>
<p>i do not think the people operating these sites are stupid. most of them are doing their best with what they were given. i think the whole chain is arranged so that nobody is in a position to notice.</p>
<h2>what i would actually change</h2>
<p>if i could put one thing on every small business site in italy, it would not be a font or a colour. it would be the phone number, in text, visible without scrolling, at the top.</p>
<p>that single change would outperform most of the design work being sold. a person on a phone in a street who wants to call you does not need a hero section. they need to see whether you exist and whether you answer.</p>
<p>after that:</p>
<ul>
<li>make it load on a phone, on a bad connection, without waiting</li>
<li>write the text in the voice of the person who runs the shop</li>
<li>delete every icon that is not carrying information</li>
<li>let the page be a page</li>
</ul>
<h2>the part where i am the problem</h2>
<p>i build sites for a living, in the same country, in the same market. it would be very convenient for me if the answer were "the italians are bad at this".</p>
<p>it is not that simple. i have been handed bad briefs. i have quoted for work that should not have been quoted for. i have shipped things i was not proud of because the scope was a template plus a logo and nobody had asked for more.</p>
<p>the market that produces the four-hundred-euro plumber site also produces the person who thinks a site is a thing you buy once. i am inside that market. so is everyone who ships here.</p>
<p>if the whole local web is bad, that includes the part of it that is mine.</p>
<h2>what i take from it</h2>
<ul>
<li><strong>the tool is rarely the problem.</strong> the person choosing it is</li>
<li><strong>template installs are not design and should not be sold as design.</strong> the buyer deserves to know which one they are getting</li>
<li><strong>the fastest site on a bad connection wins</strong>, and that is a technical decision made once, not a visual one</li>
<li><strong>the phone number at the top beats every hero section</strong> i have ever been paid to design</li>
<li><strong>i am part of the market i am complaining about</strong>, which is the part that makes it awkward to post</li>
</ul>
<p>i still think most of it is bad. i just do not think "bad" is a nationality here.</p>]]></content:encoded>
    <pubDate>Sun, 04 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>my minecraft mod shipped telemetry to a webhook with no limits on it</title>
    <link>https://eneawork.it/blog/minecraft-telemetry-webhook</link>
    <description><![CDATA[i added anonymous usage reporting to a java mod and pointed it at a webhook. no rate limit, no consent, no authentication. within days the channel was flooded with thousands of identical reports from every client at once...]]></description>
    <content:encoded><![CDATA[<p>i wrote a minecraft mod in java. a small quality-of-life thing, the kind of project you start on a weekend because you want a slightly better night-vision toggle. and somewhere in the middle of it i decided it would be interesting to know how many people were actually using it.</p>
<p>so i added telemetry. anonymous, aggregate, harmless. the mod posted a small json payload to a webhook every so often.</p>
<p>here is what i did not add:</p>
<ul>
<li><strong>a rate limit</strong> on the receiving end</li>
<li><strong>a consent switch</strong> anyone could turn off</li>
<li><strong>authentication</strong> of any kind</li>
<li>any reasoning at all about the fact that a url inside a client is a url everybody can read</li>
</ul>
<h2>what actually happened</h2>
<p>the mod's source is public, so the endpoint was public. within days the channel was not receiving telemetry anymore, it was receiving the same line thousands of times: one real user, then a script, then a script with a loop, then two scripts with a loop.</p>
<p>the payload was worthless but the volume was not. and the failure mode was the embarrassing one: <strong>i could no longer tell my own users apart from the abuse</strong>, because i had built something where those two things looked identical from the receiving side.</p>
<h2>why the data being harmless was not a defence</h2>
<p>this is the part i keep coming back to. the payload contained nothing. a script does not care. the only property that made my endpoint dangerous was that it accepted anything from anyone, as fast as the network could deliver.</p>
<p>saying "it is only telemetry" is not a security argument. it is a description of what the attacker does not care about.</p>
<h2>the fix</h2>
<p>i did not patch the mod and hope nobody looked again. i put a filter in front of the endpoint, at the edge, in cloudflare, so that the requests are rejected before they ever reach anything i own. the edge is the only place that can say no to traffic it did not let me invite in the first place.</p>
<p>it is one rule, and it is the smallest and best piece of security work i have ever done.</p>
<h2>what i took from it</h2>
<ul>
<li><strong>an endpoint is public the moment you ship it</strong>, no matter who you meant it for</li>
<li><strong>limits belong at the edge</strong>, not in the client that asked for them</li>
<li><strong>if you cannot tell a user from an attack, you do not have telemetry, you have a firehose</strong></li>
<li><strong>deleting something is a valid security control</strong>, and it should have been my first option instead of my last</li>
</ul>
<p>the mod is still out there. the telemetry is not.</p>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>my portfolio is 42 repositories and it says nothing</title>
    <link>https://eneawork.it/blog/forty-two-repositories</link>
    <description><![CDATA[15 of them are forks, 12 have no description, and there are 14 stars in total. a profile page answers "what exists". a portfolio has to answer "why". i built this site because i ran out of ways to answer the second question with a list.]]></description>
    <content:encoded><![CDATA[<p>the github profile behind this site has <strong>42 public repositories</strong>. it is the most complete thing i have ever made and it is also the least useful.</p>
<p>the entire self-description on that account is six characters: <code>20x dev</code>.</p>
<p>this is the post i would put first, because it is the reason any of the rest of this exists.</p>
<h2>what 42 repositories actually looks like</h2>
<p>i measured the profile with the github api instead of guessing, and here is the whole thing:</p>
<table>
<thead>
<tr>
<th>what</th>
<th>how many</th>
</tr>
</thead>
<tbody>
<tr>
<td>public repositories</td>
<td><strong>42</strong></td>
</tr>
<tr>
<td>of those, forks</td>
<td>15</td>
</tr>
<tr>
<td>with no description at all</td>
<td>12</td>
</tr>
<tr>
<td>total stars, all 42 combined</td>
<td>14</td>
</tr>
<tr>
<td>total forks</td>
<td>1</td>
</tr>
<tr>
<td>first commit</td>
<td>2023-01-29</td>
</tr>
<tr>
<td>most recent push</td>
<td>2026-09-22</td>
</tr>
</tbody>
</table>
<p>and the languages, which is where the profile stops being about me:</p>
<table>
<thead>
<tr>
<th>language</th>
<th>repos</th>
</tr>
</thead>
<tbody>
<tr>
<td>JavaScript</td>
<td>11</td>
</tr>
<tr>
<td>Java</td>
<td>9</td>
</tr>
<tr>
<td>Python</td>
<td>6</td>
</tr>
<tr>
<td>nothing (no git linguist)</td>
<td>5</td>
</tr>
<tr>
<td>CSS, Vue</td>
<td>2 each</td>
</tr>
<tr>
<td>Rust, OCaml, PowerShell, Brainfuck, TypeScript, Svelte, HTML</td>
<td>1 each</td>
</tr>
</tbody>
</table>
<p>nine Java repositories. almost all of them minecraft: a client, a plugin, a bounties addon, a villager-in-a-bucket, a mirrored public release. that is a real and honest part of my history — i was a kid who liked building things for a game — but it is not a design portfolio, and it is not what i do now.</p>
<p>so a visitor lands on this profile and concludes one of two things: either this person stopped being interesting around 2023, or this person has never had an opinion about anything. <strong>both readings are wrong, and the page gives them no way to find out.</strong> that is not a formatting problem. it is an argument that was never made.</p>
<h2>why a list is not a portfolio</h2>
<p>a list answers "what exists". a portfolio has to answer "why".</p>
<p>42 rows of <code>name · language · stars · description</code> is a <em>complete</em> answer to the first question and a <em>zero</em> answer to the second. completeness is not a substitute for argument — if anything it hides the argument, because 42 rows feel like evidence and none of them is a reason.</p>
<p>the specific failure here is that <strong>a row cannot contain a decision</strong>. every row is the same shape, so every row says the same thing, and the only thing distinguishing them is a number that measures other people's attention. order the page by stars and you get four projects and a lot of empty space. order it by <code>pushed_at</code> and you get a changelog.</p>
<h2>what i actually show: 9 of 42</h2>
<p>this site shows <strong>nine</strong>. that is 21% of the profile, and the selection is nine lines of a build script, not a query:</p>
<pre><code class="language-ts">const FEATURED: { name: string; tags?: string[] }[] = [
  { name: "portfolio-finder" },
  { name: "dektop-cleaner" },
  { name: "terraria_mods_extractor" },
  { name: "4webvideo" },
  { name: "sorting-visualizer", tags: ["JavaScript", "React", "Visualization"] },
  { name: "wplace-theme-changer" },
  { name: "darkwplace-extension" },
  { name: "task-manager" },
  { name: "Frenxys" },
];
</code></pre>
<p>that array is the portfolio. not the API response, not the database — nine names in the order i want them seen, with everything else fetched and then deliberately not shown.</p>
<p><strong>the order is the first design decision and it is the one i fought hardest.</strong> the api hands me the profile sorted by <code>updated</code>, which is the correct answer to "what did you touch last" and a useless answer to "what should i look at". the fetch keeps my order and discards the sort:</p>
<pre><code class="language-ts">/** keeps the curated order and warns loudly if a featured repo disappears. */
function selectFeatured(repos: GhRepo[]) {
  for (const entry of FEATURED) {
    const repo = byName.get(entry.name.toLowerCase());
    if (!repo) { missing.push(entry.name); continue; }
    picked.push(repo);
  }
</code></pre>
<p>a project from 2025 sits next to one from 2026 because that is the argument, not because of a timestamp. recency is not an argument.</p>
<p>the second decision is quieter: <strong>the featured nine have 8 stars between them</strong>, and four of them have zero. if i curated by stars the page would be empty. stars are a property of other people's attention, not of the work.</p>
<h2>each project, and the decision it is standing in for</h2>
<p>this is the part a list cannot do. every card on the projects grid is here because it stands for something i decided about how this site behaves:</p>
<ul>
<li><strong>portfolio-finder</strong> (JavaScript, 3 stars) — a tool that finds other people's portfolios. it is here because this site does the same thing at build time: fetch once, generate a file, and let nobody query github at runtime. the fallback is a hardcoded list in <code>constants.ts</code>, and it exists for the same reason that tool exists.</li>
<li><strong>dektop-cleaner</strong> (JavaScript/electron, 2 stars) — it is here to make the point about <code>pushedAt</code>: the field is carried into the data model and never used to sort. a 2025 tool earns the same card as a 2026 one.</li>
<li><strong>terraria_mods_extractor</strong> (PowerShell, 0 stars) — a single shell script, and it gets the same card as a react app. <code>buildTags</code> always puts the language first, so the badge reads <code>POWERSHELL</code> in the same slot with the same weight. the grid does not rank by prestige, because a grid that ranks by prestige is a leaderboard.</li>
<li><strong>4webvideo</strong> (Python, 0 stars) — it is here for a decision i <em>didn't</em> make: there is no language filter and no search over nine items. both would cost more in code and in visual noise than they would return.</li>
<li><strong>sorting-visualizer</strong> (JavaScript/React) — the only project with hand-written tags, because <code>FEATURED</code> entries accept a <code>tags</code> override. the automated metadata is a default, not a cage: when the machine cannot describe the work, the human writes the line.</li>
<li><strong>wplace-theme-changer</strong> and <strong>darkwplace-extension</strong> (JavaScript, 1 star each) — both are the same problem i later solved for myself: repaint a thing you do not own, at runtime, without reloading. the browser extension theme, the app theme. they are adjacent to the theming system of this site for a reason, and that reason is the only reason they are on the page.</li>
<li><strong>task-manager</strong> (JavaScript) — its github description is <em>literally just a url</em>: <code>https://taskmanager-enea.web.app/</code>. that is why the <code>Project</code> type has a <code>homepage</code> field at all. the description should point at the running thing, and when it does not, the model has somewhere else to put it.</li>
<li><strong>Frenxys</strong> (Brainfuck, 1 star) — a joke repo: a readme, and the same readme written in brainfuck. it is featured because curation is editorial and a portfolio does not have to be uniformly serious. if i removed everything unserious i would also remove everything that made the account worth looking at.</li>
</ul>
<h2>the part i deleted, which is the most honest thing here</h2>
<p>the curated list used to have hand-written entries for four older projects. i removed them, and the comment in <code>constants.ts</code> says why:</p>
<blockquote>
<p>the previous hand-written entries (demarkify, torr, beetrap, sniffcli and friends) were removed: those repositories only live under the old owner account and 404 under Frenxys, so keeping them would ship dead links.</p>
</blockquote>
<p>i checked, and it is true. all four return <strong>404</strong> under <code>Frenxys</code> and <strong>200</strong> under the old account. so my curated portfolio was shipping links to nothing.</p>
<p>that is worse than having no portfolio, because it <em>looks</em> curated. a dead link in a hand-picked list is a lie with a design around it: it says "i chose this" while pointing at nothing. <strong>i would rather have nine live cards than fifteen dead ones</strong>, and i deleted four to make the number true.</p>
<h2>what building this site actually cost</h2>
<p>because a post like this can turn into self-congratulation, the invoice. this site is a client-rendered react app with:</p>
<ul>
<li><strong>189 urls</strong> in the sitemap — 35 routes, 7 of them chrome and 28 of them posts, where each url exists only in the locales that actually have that content — and a generator that <strong>fails the build</strong> if a post in the code has no url in the sitemap, which is the only way i found to stop orphan pages</li>
<li>a build-time github fetch with a fallback, so no visitor ever queries github</li>
<li>a service worker, hashed assets, runtime theme repainting, a <code>q</code>/ctrl+c terminal version served to <code>curl</code></li>
<li><strong>28 posts, and for months only 8 of them were actually published</strong> — a <code>with_placeholder_copy</code> helper was rewriting the other 20 with lorem ipsum at runtime, so finished drafts that had been sitting in the code for half a year shipped as placeholder text</li>
<li>a <code>check:dist</code> gate that had <strong>never once failed</strong>: <code>ok()</code> printed <code>FAIL</code> and then forgot to count it, so 1,710 assertions ran to no effect</li>
</ul>
<p>i spent all of that on the frame. <strong>nine projects are visible.</strong> that ratio is the whole point, and it is the argument for the frame: the list was never the problem, so effort spent on the list buys nothing.</p>
<h2>why this site exists</h2>
<p>a github profile is a claim with no argument. this site is the argument.</p>
<p>it exists so that every claim can be followed by the code that makes it and, where i got it wrong, the post where i say so. eight of these posts are real and every single one ends with something i got wrong — a sniffer that cannot detect what its readme claims, a honeypot whose bind failure is silent, a sitemap that orphaned 37 pages, a theme that shipped with hardcoded shadows, a bug i fixed with three edits and never diagnosed, a fallback that needed a rewrite for one bot, and a privacy audit that found three third-party origins i had no idea were there.</p>
<p><strong>that is the portfolio.</strong> not the 42 repositories — the ability to show the work and then refuse to hide the seams. a link list cannot do that. it can only point at a repository and hope you will not read it.</p>
<h2>what i would tell my past self</h2>
<ul>
<li><strong>completeness is not an argument.</strong> 42 is not better than 9. if you cannot say why a thing is in the list, you have not curated, you have mirrored — and the 15 forks are proof that i know what mirroring looks like.</li>
<li><strong>delete before you ship dead links.</strong> a curated list with a broken entry is worse than no curated list, because the curation is the claim and the link is the evidence.</li>
<li><strong>the list was never the problem.</strong> i kept thinking the fix was to write better descriptions. the fix was to have something to say.</li>
</ul>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>privacy is a list, not an adjective: what this site actually sends</title>
    <link>https://eneawork.it/blog/what-this-site-actually-sends</link>
    <description><![CDATA[i was going to write that this site sends nothing anywhere. then i opened the network panel and counted. four requests, four parties, and two of them exist only to work out where i am. the honest version of the claim is a list, so here it is.]]></description>
    <content:encoded><![CDATA[<p>i was going to write that this site does not send anything anywhere. no analytics, no trackers, no cookies, everything first-party. the sentence was already written in my head and it felt great.</p>
<p>then i opened the network panel and counted, because a claim like that is either true or a lie and there is no third option.</p>
<p><strong>it sent three requests to three different origins on a cold load, and the source contains two more that hand a stranger my IP address.</strong></p>
<h2>what actually leaves the browser</h2>
<p>this is the network log of a cold load of the homepage, complete:</p>
<table>
<thead>
<tr>
<th>destination</th>
<th>what it is</th>
<th>who they learn</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fonts.googleapis.com</code></td>
<td>css for <strong>JetBrains Mono</strong></td>
<td>that you are on this site</td>
</tr>
<tr>
<td><code>fonts.gstatic.com</code></td>
<td>the JetBrains Mono woff2</td>
<td>ditto</td>
</tr>
</tbody>
</table>
<pre><code>there is a fourth row, and it is the only one i chose to put there:

| destination | what it is | who they learn |
| --- | --- | --- |
| `www.googletagmanager.com` | Google Analytics 4 | **that you visited a page here, and when** |

that one is real, i added it on purpose, and it does not fire unless you press a button first. `src/lib/analytics.ts` keeps the answer in `localStorage`, and `gtag.js` is injected only after `granted`. refuse, and the script is never fetched and a `no` is remembered; ask again on every visit and some people read that as pressure, so it is asked once. **before you choose, this table is three rows, not four.**

that last row is the one i need to talk about. the full request was:
</code></pre>
<pre><code>/v1/forecast?latitude=44.4949&#x26;longitude=11.3426&#x26;current=temperature_2m,...
</code></pre>
<p>my coordinates, in plaintext, in a url, to show me the temperature. and here is where they came from — or rather, where they <em>can</em> come from, because on this particular load they did not need to. <code>detectUserLocation()</code> in <code>HomePage.tsx</code> tries <code>get.geojs.io/v1/ip/geo.json</code> and, if that fails, <code>ipwho.is</code>. <strong>both of those are IP geolocation services</strong>, which means both of them are handed your IP address as the price of a postcode. they did not fire here because the widget already had a location; when it does not, a visitor's IP goes to two strangers in sequence so that a dropdown can be skipped.</p>
<p>so the honest count is three requests on a cold load and five endpoints in the source, and four of them exist to serve a single widget. i built that without a moment of thought.</p>
<p>what is <em>not</em> there is still the more interesting part, even after the analytics. <strong>no tag manager.</strong> no error reporting. no session replay. no pixel, no beacon, no <code>document.cookie</code> anywhere in the source. settings live in <code>localStorage</code> and go nowhere. the app, the styles, the images, the two UI fonts: all from my own origin, one hostname, no third party.</p>
<pre><code>that last sentence is worth defending properly, because "no third party" is not true and never was — the table above is three rows long and every one of them is somebody else's server. what is true is narrower: **the app itself never talks to anyone.** one origin serves the code, the styles and the images. the third parties are called by the browser, from the page, and the two that matter can be switched off without touching a line of the app.

and the analytics row is the honest kind of addition. i could have written this post a year from now and quietly let the sentence at the top rot, and that is the move i did not make, because a privacy post that goes stale is worse than no privacy post: it teaches people the wrong lesson about the value of checking.
</code></pre>
<h2>the part that is genuinely good, and that nobody screenshots</h2>
<p><strong>the favicon costs zero requests.</strong> this site repaints its tab icon at runtime, in the current theme colour, from the sigma logo path baked into the bundle:</p>
<pre><code class="language-tsx">const href = `data:image/svg+xml;utf8,${encodeURIComponent(buildSigmaFavicon(color))}`;
document.querySelectorAll&#x3C;HTMLLinkElement>('link[rel~="icon"]').forEach((link) => {
  link.href = href;
});
</code></pre>
<p>switch the accent and the tab icon follows, because it is a data uri, so there is nothing to fetch. <strong>there is no <code>/favicon.ico</code> entry in the network log at all.</strong> the static <code>.ico</code>, the apple-touch-icon and the manifest pngs are still there, and they have to be: they are what you get before react mounts and on the clients that cannot run javascript or cannot render svg. the last <code>&#x3C;link></code> wins where both are supported, so the vector one goes last.</p>
<p>it is a small thing, and it is the clearest example of privacy-by-construction i have in the codebase: the feature is not "we do not track you", it is "this specific request does not exist".</p>
<p><strong>no sourcemaps.</strong> one line in <code>vite.config.ts</code>:</p>
<pre><code class="language-ts">build: {
  sourcemap: false,
}
</code></pre>
<p>a sourcemap is a complete map of your source, shipped to everyone who loads the page, usually by accident because it was useful once in devtools. it is the most private setting in a bundler and the easiest to forget. <code>dist/</code> has no <code>.map</code> file in it.</p>
<h2>hashed filenames are not privacy</h2>
<p>every asset on this site has a content hash in its filename: <code>a/</code> and <code>c/</code> for the js chunks, <code>s/</code> for the css. i am deliberately not pasting one here, because it changes on every build and this paragraph would be wrong by tomorrow.</p>
<p>people list hashed filenames under privacy posts. it is not privacy, it is caching: the hash is there so a deploy does not invalidate a year of browser cache. the hash has nothing to do with anyone's address.</p>
<p>i am writing it here because conflating the two is exactly what makes "privacy website" posts worthless. a bundle with a content hash in the name is not private. a 10 MB third-party script is not private either, whether its url has a hash in it or not.</p>
<p>for what it is worth, "size" means three different numbers for the same entry chunk: the file on disk, what a gzip server would send, and what brotli actually puts on the wire. that last one lands at roughly a quarter of the first. i am not pasting exact byte counts here, and the reason is embarrassing: <strong>this post is inside the bundle it is describing</strong>, so every number i write changes the number, and the paragraph would be wrong by the next deploy. one entry chunk, hashed, and about a quarter of its on-disk size on the wire.</p>
<h2>the jetbrains mono problem, which is embarrassing</h2>
<p>everything above about self-hosting fonts is true, and then there is this, on line 1 of <code>src/index.css</code>:</p>
<pre><code>@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&#x26;display=swap");
</code></pre>
<p>and the variable right below it:</p>
<pre><code>--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
</code></pre>
<p>two problems, and the second one is the interesting one.</p>
<p>the first is that every other typeface on this site is served from my own origin — <code>GoogleSansFlex-Variable.woff2</code> and <code>MaterialSymbolsRounded.woff2</code>, both in <code>public/fonts/</code>, both preloaded — and one <code>@import</code> puts google back in the room. a visitor's browser now tells google which page they are on and roughly where from, in exchange for a monospace font that most of them will never see rendered.</p>
<p>the second is that <strong>the font is behind a setting</strong>. there is a "developer font" toggle in the settings dialog that switches the whole ui to JetBrains Mono. almost nobody turns it on. and the <code>@import</code> does not care: a css <code>@import</code> at the top of a stylesheet is unconditional, so it is fetched on every cold load whether you ever open that toggle or not. the privacy-relevant code path and the user-facing code path are not the same code path, and the convenience one is the one that always runs.</p>
<p>the fix is one line. the stack already has <code>ui-monospace</code>, <code>SFMono-Regular</code> and <code>monospace</code> behind it — three fonts that cost zero requests — so deleting the import degrades nothing except the glyph shapes.</p>
<h2>the fonts i ship and nobody loads</h2>
<p>this is not privacy, it is honesty about my own repo:</p>
<table>
<thead>
<tr>
<th>file</th>
<th>bytes</th>
<th>referenced by</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MaterialSymbolsRounded.woff2</code></td>
<td>5,345,304</td>
<td><code>index.html</code>, <code>index.css</code></td>
</tr>
<tr>
<td><code>GoogleSansFlex-Variable.woff2</code></td>
<td>1,945,520</td>
<td><code>index.html</code></td>
</tr>
<tr>
<td><code>RobotoFlex-Variable.ttf</code></td>
<td>1,787,292</td>
<td><strong>nothing</strong></td>
</tr>
<tr>
<td><code>GoogleSansFlex-Latin.woff2</code></td>
<td>20,076</td>
<td><strong>nothing</strong></td>
</tr>
</tbody>
</table>
<p>9,098,192 bytes in <code>dist/fonts/</code>, 1,807,368 of which no page ever asks for. they are in <code>public/</code>, so they are copied into every deploy, they are fetchable by anyone who guesses the url, and they will still be there in three years because nothing warns you about a file that is never requested.</p>
<p>and the 20,076-byte one is embarrassing in a specific way: <strong>it is the 20 KB latin subset I wrote a whole post about.</strong> i measured it, i shipped the 1,945,520-byte variable font instead because i decided the full one was the right call, and then i never deleted the subset. the file survived the decision that overruled it. the number in my post is still correct and the file is still unused.</p>
<h2>what "privacy as a design choice" has to mean</h2>
<p>not a disclaimer. not a cookie banner with nothing to consent to. the test is one question:</p>
<p><strong>can you state, without hedging, what leaves the browser?</strong></p>
<p>if you cannot, you have a privacy policy. a policy is a document you wrote once. if you can — and the list is short, and every entry is a feature you chose on purpose — then you have a design decision. mine is four requests, and after today i can name all four and decide about each one.</p>
<p>that is the whole discipline. not "we respect your privacy". a list, checked against the network panel, with the coordinates in it.</p>
<h2>what i would change, in this order</h2>
<p><strong>1. delete the <code>@import</code>.</strong> the fallback stack is already there. one line, removes two third-party origins, changes nothing anyone can see unless they enabled a setting most people never touch.</p>
<p><strong>2. put the weather behind a click.</strong> this is the real failure and it is mine, not theirs: to save myself and the visitor one dropdown interaction, two companies learn where the visitor is and a third is told. the geolocation should happen after a click on the weather widget, never before, and never by default. if the widget is not clicked, nobody should be located.</p>
<p><strong>3. delete the two dead fonts.</strong> 1.8 MB that no page requests, in every deploy, forever.</p>
<h2>what i would tell my past self</h2>
<ul>
<li><strong>a privacy claim is a list with names in it.</strong> "sends nothing" is an adjective. "sends nothing except these four things, and i can see all four in the panel" is a fact you can be caught on, which is the point.</li>
<li><strong>an <code>@import</code> is not a setting.</strong> i thought i had made the monospace font optional. i had made it <em>visible</em> or not, which is not the same thing, and the network request was there either way. the feature and the privacy are different code paths and only one of them is behind the toggle.</li>
<li><strong>the numbers in your posts rot faster than your bundles.</strong> the 20 KB subset is still sitting in this repo, unused, being the exact size i wrote about while the 1.9 MB file ships. if you ever correct a number, check whether the file it described still exists — or delete it while you are there.</li>
</ul>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the bug that taught me what is not a bug</title>
    <link>https://eneawork.it/blog/animatepresence-mode-wait-bug</link>
    <description><![CDATA[clicking "blog" changed the url and left the page on the screen. i deleted three things at once, one of which was the culprit, and i never found out which. here is the mechanism, the three suspects, and why the answer was not "animatepresence".]]></description>
    <content:encoded><![CDATA[<p>clicking <strong>blog</strong> in the sidebar changed the url to <code>/blog</code> and left the home page on the screen. click <strong>info</strong>, same thing. the router was working, the history was working, the click handler was working. only the picture was wrong.</p>
<p>i fixed it by deleting three things at once, and i have thought about that decision more than the bug itself.</p>
<h2>the mechanism, before the fix</h2>
<p>this is what the page body used to be:</p>
<pre><code class="language-tsx">&#x3C;AnimatePresence mode="wait" initial={false}>
  &#x3C;motion.div
    key={page + (blogPostId || "")}
    initial={settings.disableAnimations ? false : { opacity: 0, y: 15, scale: 0.98 }}
    animate={{ opacity: 1, y: 0, scale: 1 }}
    exit={{ opacity: 0, y: -15, scale: 0.98 }}
    transition={/* duration, ease, spring — elided */}
  >
    {page === "home" &#x26;&#x26; &#x3C;HomePage ... />}
    {page === "blog" &#x26;&#x26; &#x3C;BlogPage ... />}
    {/* ... */}
  &#x3C;/motion.div>
&#x3C;/AnimatePresence>
</code></pre>
<p>read the <code>key</code>. it changes on every navigation, so react unmounts the old <code>motion.div</code> and mounts a new one, and <code>AnimatePresence</code> exists to keep the old one alive long enough to play its <code>exit</code>.</p>
<p><strong>and <code>mode="wait"</code> is the instruction not to mount the new one yet.</strong> that is the entire semantics of the mode: the incoming child stays unmounted until the outgoing child has finished leaving. the docs say it plainly, and it is a good mode. it gives you a clean cut instead of two pages fighting for the same space.</p>
<p>now put the failure condition next to it. <strong>the new page — the thing the user clicked, the thing the url already says — does not appear until an animation finishes.</strong> every frame that does not run is a page that does not render.</p>
<h2>the three things i deleted, and why i cannot tell you which one did it</h2>
<p><code>git diff src/App.tsx</code> is the whole investigation. three hunks:</p>
<p><strong>1. the presence wrapper.</strong> <code>&#x3C;AnimatePresence mode="wait" initial={false}></code> around the page body is gone. the keyed <code>motion.div</code> stays, with the same <code>key</code>, the same <code>initial</code>, the same <code>animate</code>, the same <code>transition</code> — only <code>exit</code> is gone, because there is nothing left to exit from. react still remounts on every page change, so the enter animation still plays. the page you navigated to is now rendered by react's own reconciliation, not by an animation completing.</p>
<p><strong>2. <code>React.startTransition</code> in the navigation handler.</strong> it was wrapping the two state setters:</p>
<pre><code class="language-tsx">React.startTransition(() => {
  setPage(newPage);
  setBlogPostId(postId);
});
</code></pre>
<p>and it is now:</p>
<pre><code class="language-tsx">setPage(newPage);
setBlogPostId(postId);
</code></pre>
<p>this one is a real suspect and not a stylistic preference. a transition update is, by design, <strong>interruptible and lower priority</strong> — react is allowed to yield it and pick it back up. the update that changes the key is the update that drives the presence swap. so wrapping it in a transition makes the <em>swap itself</em> interruptible, and an interrupted swap is exactly a case where the exiting child was never told to exit and the incoming child was never mounted.</p>
<p><strong>3. <code>layout</code> on the <code>&#x3C;motion.main></code> wrapper.</strong> removed. a layout projection on the parent measures and animates the child's box, and it runs as a separate pass from the presence swap. i wrote the reason in the code at the time, and i still believe it: <em>a layout projection on the wrapper fights the AnimatePresence child that is actually swapping pages, and the loser can keep the previous page mounted.</em></p>
<p>and there it is. <strong>i removed all three, in one commit, without bisecting them.</strong> i could not narrow it down, so i removed the entire configuration in which the invariant could break. that is not a diagnosis. it is a shotgun, and the honest label for it is "i did not find the bug, i removed the ability to have it".</p>
<h2>the measurement that told me the failure mode was real</h2>
<p>the environment i test in — the preview panel i keep open while i work — <strong>runs no animation frames at all.</strong> not slow frames. zero.</p>
<p>here is how i established that. the obvious version hangs:</p>
<pre><code class="language-js">new Promise(res => {
  let n = 0;
  const t0 = performance.now();
  const tick = () => {
    n++;
    if (performance.now() - t0 &#x3C; 1000) requestAnimationFrame(tick);
    else res({ rafFramesIn1s: n });
  };
  requestAnimationFrame(tick);
});
</code></pre>
<p>this should resolve after one second. it never resolves, because the only thing that can end it is a frame, and no frame ever comes. the evaluation times out and tells me the page stayed responsive — which it is. it is responsive and completely motionless.</p>
<p>so i asked again without hanging:</p>
<pre><code class="language-js">requestAnimationFrame(function tick() { frames++; requestAnimationFrame(tick); });
// ... 2.5 seconds later
{ "rafFramesAfterWait": 0 }
</code></pre>
<p><strong>zero frames in two and a half seconds.</strong> which means that in this environment <code>exit={{ opacity: 0, y: -15 }}</code> can never complete, <code>mode="wait"</code> can never release the incoming child, and the correct page is unconditionally unreachable. the bug was not intermittent there. it was certain.</p>
<h2>what is not a bug</h2>
<p>this is the part i want to be careful about, because the easy conclusion is wrong.</p>
<p><strong>it is not a bug in <code>AnimatePresence</code>.</strong> that component is still in this codebase <strong>eleven times across six files</strong>, and every one of them works:</p>
<table>
<thead>
<tr>
<th>where</th>
<th>what <code>mode="wait"</code> gates</th>
<th>why it is safe</th>
</tr>
</thead>
<tbody>
<tr>
<td>HomePage ×2</td>
<td>the rotating headline words</td>
<td>decoration; if it stalls you see the previous word</td>
</tr>
<tr>
<td>BlogPage</td>
<td>the featured post block</td>
<td>secondary content on a page that already rendered</td>
</tr>
<tr>
<td>Code.tsx</td>
<td>the language label on a code block</td>
<td>a badge</td>
</tr>
<tr>
<td>CopyLinkCapsule ×2</td>
<td>the "copied!" label</td>
<td>a 400ms confirmation</td>
</tr>
<tr>
<td>SettingsDialog ×4</td>
<td>the panel behind a tab</td>
<td>the tab itself already tells you where you are</td>
</tr>
<tr>
<td>TechStack</td>
<td>one tech item in a list</td>
<td>the rest of the list is already there</td>
</tr>
</tbody>
</table>
<p>not one of them gates a <strong>route</strong>. and that is the actual rule i should have written down before the bug instead of after it: <code>mode="wait"</code> is safe exactly when the gated thing is optional, and it is a correctness hazard when the gated thing is the destination. if the thing that fails to appear is what the user asked for, you have turned an easing into a precondition.</p>
<p><strong>it is not a bug in the animation either.</strong> deleting the animation would have "fixed" it — set <code>disableAnimations</code> and the exit finishes instantly — and that would have been the wrong fix twice over: the animation was never the defect, and the site would have lost its transition to paper over a state-management problem. the transition is not what i was selling. it is decoration. it should not have been able to veto a route.</p>
<p><strong>so what was the bug?</strong> an invariant, written in the wrong language. the property i needed is <em>"the page you navigated to is the page you get"</em>. i had expressed it as <em>"the page you navigated to appears after the previous one finishes animating"</em>. the first is a statement about correctness that react's reconciler guarantees. the second is a statement about timing that depends on a browser running frames. i had made a correctness property conditional on an animation, and then spent a day looking for a bug in the animation library.</p>
<h2>how i know the fix works</h2>
<p>in that same zero-frame environment, because a fix that only works where frames run is not a fix:</p>
<ul>
<li>home → blog: url <code>/blog</code>, <code>#primary-content.children.length === 1</code>, blog page rendered</li>
<li>blog → home: url <code>/</code>, <code>children.length === 1</code>, home page rendered</li>
</ul>
<p>one child, every time. that number is the whole test — it is the direct observable of the thing that was broken, and it is what i was checking by hand before. <code>kids: 1</code>, url and content in agreement, with no frames running at all.</p>
<h2>what i would tell my past self</h2>
<ul>
<li><strong>name the invariant, not the library.</strong> "the url changed and the page did not" is a bug report. "<code>AnimatePresence</code> is broken" is a mood. the first one points at a line of code; the second one points at your favourite dependency.</li>
<li><strong>a fix that changes three unrelated things is not a diagnosis.</strong> i removed the wrapper, the transition wrapper and the layout projection together and called it fixed. if i had removed one and measured, i would know which one it was — and i would have kept the other two, and this post would be about a bug instead of about a habit.</li>
<li><strong>if the only place you can reproduce it is the place you also do your work, you have a measurement problem before you have a bug.</strong> i spent this one in a preview panel that renders no frames. that is both why it reproduced and why i could never see it in a normal browser, and it means i cannot honestly claim real visitors hit it. what i <em>can</em> say is that the failing condition is one a real browser hits whenever a tab is backgrounded, a frame is dropped mid-transition, or the user navigates faster than the animation — and a bug whose only trigger is a condition you cannot see in your own dev loop is a bug that ships.</li>
</ul>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>0 lines of javascript for a crawler: the 1,416 bytes of html bing reads instead of my react app</title>
    <link>https://eneawork.it/blog/zero-js-crawler-fallback</link>
    <description><![CDATA[this site is one index.html and 539 urls, all served the same document. to a crawler that does not run javascript, the body used to be an empty div. the fix is 33 lines of html inside #root that react deletes on mount. here is what it fixes, what it quietly does not, and the two bugs in the layer above it.]]></description>
    <content:encoded><![CDATA[<p>this site is a client-rendered react app. it ships <strong>one</strong> html file. there are 539 urls in the sitemap and every one of them is served that same document, because there is no <code>/blog/index.html</code> and no <code>/it/index.html</code> — there is exactly one html file in the build output and it is 12,245 bytes.</p>
<p>so what does bing see?</p>
<p>a crawler that does not execute javascript looks at that document and finds a perfectly good <code>&#x3C;head></code> and an <strong>empty <code>&#x3C;div id="root"></code></strong>. 10,377 bytes of metadata and zero words of content.</p>
<p>the fix was to stop shipping an empty div.</p>
<h2>the fallback</h2>
<p>33 lines of plain html inside <code>#root</code>, which react throws away on mount:</p>
<table>
<thead>
<tr>
<th>measurement</th>
<th>value</th>
</tr>
</thead>
<tbody>
<tr>
<td>lines</td>
<td>33</td>
</tr>
<tr>
<td>bytes</td>
<td>1,416</td>
</tr>
<tr>
<td>words</td>
<td>101</td>
</tr>
<tr>
<td><code>&#x3C;h1></code> / <code>&#x3C;h2></code> / <code>&#x3C;li></code></td>
<td>1 / 2 / 7</td>
</tr>
<tr>
<td>links</td>
<td>7</td>
</tr>
<tr>
<td><code>&#x3C;script></code> tags</td>
<td><strong>0</strong></td>
</tr>
</tbody>
</table>
<p>one <code>h1</code>, a short paragraph that says who i am and what i do, a "services" list, a "contact" list, and four links into the site. 1,416 bytes of marketing copy that i maintain by hand, forever, for a reader that will never look at it.</p>
<p>plus 6 css rules — 13 lines, 543 bytes — so it arrives looking like a small page instead of a broken one. that part is not decoration. a fallback rendered as unstyled black-on-white text reads to a human reviewer as <em>this site is broken</em>, and the same 543 bytes are the difference between <em>this site has minimal content</em> and <em>this site is broken</em>.</p>
<h2>why it is not a duplicate of the page</h2>
<p>because <code>createRoot</code> clears its container. this is all the mount code does:</p>
<pre><code class="language-tsx">createRoot(document.getElementById('root')!).render(
  &#x3C;StrictMode>
    &#x3C;ThemeProvider>
      &#x3C;App />
</code></pre>
<p>react does not merge into <code>#root</code>, it wipes the children and puts its own. so the visitor never sees the fallback for more than a few milliseconds, and the crawler never sees react. there is no hydration mismatch to worry about and no "is the app ready yet" state to design around.</p>
<h2>the compromise, stated plainly — and then fixed</h2>
<p>i originally did not prerender. astro, or next with static export, or vite-ssg would have given every one of the urls its own html with its own body text. i did not take it, because it turns a marketing page into a second build pipeline with its own cache and its own deploy failure mode.</p>
<p>then i measured what the floor actually cost, and the answer was worse than i had assumed: <strong>374 urls, and every one of them was the same document.</strong> the canonical on all 374 said the site root, which is a way of telling a crawler that the other 373 are duplicates of the home page. one document, 374 addresses.</p>
<p>so i wrote a prerenderer instead. it emits <strong>one html file per (route, locale) pair</strong> into the build output, generated from the same data the app reads, so the two cannot disagree. <code>pnpm run build</code> now writes <strong>189 documents</strong> — 7 page routes × 7 locales, and one document per post per locale that post has been translated into. a language contributes a url the moment that one post is written, and the hreflang cluster for a post is computed from its own id, so the document and its alternates can never disagree about which languages exist.</p>
<p>here is the honest accounting, per url, now:</p>
<table>
<thead>
<tr>
<th>what</th>
<th>route-specific?</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>&#x3C;title></code>, description, canonical, hreflang</td>
<td>yes — statically in the head, one per url</td>
</tr>
<tr>
<td>JSON-LD</td>
<td>yes — <code>BlogPosting</code> and <code>BreadcrumbList</code> on posts, <code>WebPage</code> elsewhere</td>
</tr>
<tr>
<td>sitemap, rss</td>
<td>yes — 35 routes, split across the two locale clusters</td>
</tr>
<tr>
<td><strong>body text</strong></td>
<td><strong>yes on posts and on the blog index — the whole article, in that language</strong></td>
</tr>
<tr>
<td>body text on <code>/lab</code>, <code>/now</code>, <code>/lens</code></td>
<td><strong>partly.</strong> title, description and navigation, but not the prose, because that prose lives in jsx and there is no data file to read it from</td>
</tr>
<tr>
<td>the app</td>
<td>yes, one javascript bundle</td>
</tr>
</tbody>
</table>
<p>the interesting part was not writing the generator. it was discovering that the hreflang cluster had to be <strong>per kind of route</strong>. a post exists in three languages and a page in seven, so a single hardcoded list of eleven was advertising six languages that served the english original under a foreign-language tag — which google counts as duplicate content, not as localization.</p>
<p>both lists are now derived from the module files on disk. dropping <code>posts/fr.ts</code> in makes french appear, and nothing else has to change.</p>
<p>the floor is gone for the posts. it is still a floor for three pages, and i would rather say so than pretend otherwise.</p>
<h2>the layer above the fallback, and the two bugs in it</h2>
<p>the head is not the only thing that is route-specific. <code>/blog/*</code> has a cloudflare pages function that rewrites the metadata for known bots:</p>
<pre><code class="language-js">const BOT_AGENTS = [
  "Twitterbot", "facebookexternalhit", "LinkedInBot", "Slackbot",
  "TelegramBot", "WhatsApp", "Discordbot", "discordbot",
  "ia_archiver", "Googlebot", "bingbot", "Applebot",
];
</code></pre>
<p>twelve user agents. for those, <code>[id].js</code> looks the post up in a generated <code>og-data.js</code>, then rewrites <code>&#x3C;title></code>, <code>og:title</code>, <code>og:description</code>, <code>og:url</code>, <code>og:type</code>, the twitter tags and the meta description through <code>HTMLRewriter</code>. <code>bingbot</code> is in the list, and bingbot is the one that does not run javascript. so for a blog post, bing gets the real title of the real post.</p>
<p>and then it gets the homepage's 101 words underneath it.</p>
<p><strong>bug 1: <code>DuckDuckDuckBot</code> is not in that list.</strong> twelve entries, and the crawler I named in the comment directly above the fallback block — the whole reason the fallback exists — is the one that falls through to the defaults. every post on the site is titled <code>Enea | Blog</code> as far as duckduckgo is concerned. one string. that is the entire fix and i have not applied it.</p>
<p><strong>bug 2: canonical is never rewritten.</strong> the map in <code>MetaRewriter</code> includes <code>og:url</code> but there is no handler for <code>link[rel=canonical]</code>. so the same document tells social platforms <em>"this url is <code>/blog/my-post</code>"</em> and tells search engines <em>"this url is <code>https://eneawork.it/</code>"</em>. <code>og:url</code> and <code>rel=canonical</code> are not allowed to disagree, and mine do, on all 42 posts. the fix is one more entry in the map and one more <code>.on("link[rel=canonical]")</code> handler.</p>
<p>smaller: the function rewrites <code>twitter:card</code> to <code>summary_large_image</code> while <code>og:image</code> stays the 460×460 profile photo, because every post shares one image. a large card with a square avatar in it.</p>
<h2>what i will not pretend about this</h2>
<p>the fallback is <strong>marketing copy sitting in a code path</strong>. the moment the live site changes and i forget to change it, the crawler is being served a stale description of a different site, and nothing will fail. no test breaks, no type error, no build error. it is the kind of debt that only shows up in traffic you cannot attribute.</p>
<p>i have already left one small fossil in it: the line reads <code>Fotografia: &#x3C;a href="/lens">lens&#x3C;/a></code> — an italian word in an otherwise english document, in the one piece of html on this site that is supposed to be the permanent version of me. it is not a bug. it is what hand-maintained copy looks like after a year.</p>
<h2>what i would tell my past self</h2>
<ul>
<li><strong>the head is where the seo lives, and csr does not touch it.</strong> every piece of metadata that matters is static, in the document, before any javascript runs. if you write the head by hand you have already done 80% of the seo work of a static site, and the remaining 20% is the thing people mean when they say "you should prerender".</li>
<li><strong>a fallback has to be the shortest thing that is still true.</strong> 1,416 bytes is a paragraph. 1,416 bytes of per-route content is 539 documents. the first one is a weekend; the second one is a platform.</li>
<li><strong>when you write a comment naming a threat, check that you actually handled it.</strong> i wrote "bing, duckduckgo and ai crawlers that do not run javascript" three lines above a fallback, and then shipped an allowlist of twelve bots with the second name missing. the comment was more thorough than the code. it usually is.</li>
</ul>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i wrote a honeypot in go that answers ssh, ftp and http without speaking any of them</title>
    <link>https://eneawork.it/blog/beetrap-200-lines-of-honeypot</link>
    <description><![CDATA[200 lines of go, three hardcoded ports and two fake banners. it never completes a handshake, never sees a password, and the readme tells you to grant a linux capability the code never uses. here is what it actually catches, and the two bugs i would fix before trusting it.]]></description>
    <content:encoded><![CDATA[<p>i wanted to see what knocks on my door at three in the morning. so i wrote <a href="https://github.com/hnpf/beetrap">beetrap</a>: it listens on three ports, sends a fake service banner, reads one line back, prints it in a tui, and hangs up.</p>
<p>that is the entire product. there is no more to it than that.</p>
<h2>what it actually is</h2>
<p>three go files, 200 lines, 4,720 bytes of source:</p>
<table>
<thead>
<tr>
<th>file</th>
<th>lines</th>
<th>bytes</th>
<th>job</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cmd/beetrap/main.go</code></td>
<td>19</td>
<td>367</td>
<td>make a channel, start the tui</td>
</tr>
<tr>
<td><code>internal/capture/capture.go</code></td>
<td>58</td>
<td>1,119</td>
<td>listen, greet, read one line</td>
</tr>
<tr>
<td><code>internal/ui/ui.go</code></td>
<td>123</td>
<td>3,234</td>
<td>draw the log, count the hits</td>
</tr>
</tbody>
</table>
<p><code>go.mod</code> has two direct dependencies and <strong>both of them are the tui</strong>: bubbletea 0.27.0 and lipgloss 0.12.1. the other 18 are indirect, and every single one of them arrives because of the terminal.</p>
<p>the part i did not expect: <strong>the whole network side has zero third-party code.</strong> <code>capture.go</code> imports <code>bufio</code>, <code>fmt</code>, <code>io</code>, <code>net</code>, <code>strings</code>, <code>time</code> — all standard library, all of it. the "protocol implementation" is a map from a string to a string:</p>
<pre><code class="language-go">var _banners = map[string]string{
	"SSH":  "SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6\r\n",
	"FTP":  "220 (vsFTPd 3.0.5)\r\n",
	"HTTP": "",
}
</code></pre>
<p>that is the entire protocol knowledge in the project, and it is 1,119 bytes long.</p>
<h2>why go, specifically</h2>
<ul>
<li><strong>no root, by default.</strong> the ports are 2222, 2121 and 8080, all above 1023, so the first run needs no privileges. a python honeypot would give me the same thing. this is not a reason to like go.</li>
<li><strong>one goroutine per connection, in one line.</strong> <code>go _handle(conn, service, ch)</code> inside the accept loop. the same shape in java is a thread pool, a bounded queue and a rejection policy. here it is a keyword, and the failure mode is a slowloris sitting on a goroutine for eight seconds.</li>
<li><strong>one static file.</strong> <code>go build -o beetrap ./cmd/beetrap</code> produces a single executable with no interpreter, no venv, no <code>node_modules</code>. a honeypot is something you leave running on a machine nobody maintains, so the fewer moving parts the better.</li>
<li><strong>the tui libraries.</strong> bubbletea gives you the update-loop architecture for a terminal, lipgloss does the styling. for a read-only log that never scrolls back, the framework costs about 120 lines and saves me from hand-rolling ansi escapes and parsing terminal width.</li>
<li><strong><code>net</code> was already in the box.</strong> the honest reason: i had never written a listener before, and <code>net.Listen</code> + <code>Accept</code> made it look like four lines. i did not choose go for concurrency. the concurrency came along for free and i never had to earn it.</li>
</ul>
<h2>how i structured it, and why the structure is wrong</h2>
<p>there is no router. there are three goroutines, hardcoded, started from the tui's <code>Init()</code>:</p>
<pre><code class="language-go">func (m VxModel) Init() tea.Cmd {
	go capture.VxStartListener("SSH", 2222, m.ch)
	go capture.VxStartListener("FTP", 2121, m.ch)
	go capture.VxStartListener("HTTP", 8080, m.ch)
	return _wait(m.ch)
}
</code></pre>
<p>that is the routing table: three tuples, in a function that has nothing to do with networking.</p>
<p>the layering is inverted and i would rather name it than quietly refactor it. <code>internal/capture</code> is the package that actually opens sockets, and it has no idea which ports it serves or why. <code>internal/ui</code> — the package whose entire job is drawing coloured text in a terminal — owns the deployment topology. if i add telnet, i edit the drawing code.</p>
<p>the right shape is a config struct built in <code>main</code>, one loop over a slice of services, and a package boundary that knows nothing about ports. i would also make the service name a type instead of a bare <code>string</code>, because right now <code>"SSH"</code> is a lookup key in two maps living in two packages and nothing checks that the two maps agree.</p>
<p>connections reach the screen through one buffered channel of size 32, created in <code>main</code>:</p>
<pre><code class="language-go">ch := make(chan capture.VxConnection, 32)
p := tea.NewProgram(ui.VxNewModel(ch), tea.WithAltScreen())
</code></pre>
<p>the ui never calls the network. it waits on the channel, receives one message, appends it, re-arms the wait. that is the same message loop as the frontend of this site, for the same reason: <strong>the thing that draws must not be the thing that blocks.</strong> the accept loop only ever writes to a channel and never touches the view.</p>
<h2>what the protocols taught me, and i did not expect any of it</h2>
<p>the thing i got wrong in my head before writing it: i assumed a honeypot has to speak the protocol. it does not. three services, three completely different rules about who talks first:</p>
<table>
<thead>
<tr>
<th>service</th>
<th>port</th>
<th>who speaks first</th>
<th>what we send</th>
<th>what we capture</th>
</tr>
</thead>
<tbody>
<tr>
<td>SSH</td>
<td>2222</td>
<td>the server</td>
<td><code>SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.6</code></td>
<td>the client's own version string</td>
</tr>
<tr>
<td>FTP</td>
<td>2121</td>
<td>the server</td>
<td><code>220 (vsFTPd 3.0.5)</code></td>
<td><code>USER &#x3C;someone></code></td>
</tr>
<tr>
<td>HTTP</td>
<td>8080</td>
<td>the client</td>
<td>nothing at all</td>
<td><code>GET / HTTP/1.1</code>, or worse</td>
</tr>
</tbody>
</table>
<p><strong>ssh sends its identification string first.</strong> it is the rare protocol where the server speaks unprompted and the client answers with its own. so the line we read back is not a credential, it is a fingerprint: <code>SSH-2.0-OpenSSH_9.6</code> tells you the client is a real openssh, which version, on which distribution. that is the whole payload of a scanner handshake, and you get it from a single line.</p>
<p><strong>ftp's first line is the username.</strong> the server's <code>220</code> is the greeting, and the very next thing a client says is <code>USER root</code>, or <code>USER admin</code>, or <code>USER anonymous</code>. one line into the protocol and you have the credential list a bot is walking down, before it has sent anything secret.</p>
<p><strong>http is the inverse, and the empty string in the map is not a bug.</strong> an http server that writes anything before reading the request is broken. the map has <code>"HTTP": ""</code> and the handler guards with <code>if b := _banners[service]; b != ""</code> — that guard is the entire reason http behaves, and it is one line that happens to be load-bearing for two protocols and correct for the third.</p>
<p>the general lesson: <strong>a honeypot's first read is the highest-value byte it will ever see.</strong> identification, not authentication. you learn who is out there from the handshake, and you never have to implement the part where they log in.</p>
<h2>what it does not do</h2>
<ul>
<li><strong>it never sees a password.</strong> no key exchange, no <code>none</code> auth method, no <code>pam</code>, no <code>/etc/shadow</code>. the socket is greeted, one line is read, <code>defer conn.Close()</code> fires. a real <code>ssh</code> client waits out the 8-second deadline and gives up. that is the correct design, not a missing feature: a honeypot that accepts logins is a liability with a login form on it.</li>
<li><strong>it captures one line, 512 bytes, inside 8 seconds.</strong> <code>io.LimitReader(conn, 512)</code> then <code>ReadString('\n')</code>. a client that sends a binary blob with no newline gets whatever <code>ReadString</code> returns, which is garbage. fine, as long as i know it.</li>
<li><strong>one goroutine per connection, unbounded.</strong> nothing caps concurrency. a socket that connects and sends nothing holds a goroutine for the full eight seconds. a thousand of those is a thousand goroutines. survivable on a personal machine, rude on a small vps.</li>
<li><strong>nothing is written to disk.</strong> the log lives in a slice in ram, trimmed to <code>height - 10</code> rows only when it is drawn. press <code>q</code> and it is gone. a honeypot that forgets is a screensaver with a network interface.</li>
<li><strong>the ports cannot be changed without editing go source.</strong> there are no flags, no env vars, no config file.</li>
</ul>
<h2>the two bugs i would fix before trusting it</h2>
<p><strong>1. a failed bind is completely silent.</strong> <code>VxStartListener</code> starts like this:</p>
<pre><code class="language-go">ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
	return
}
</code></pre>
<p>the error is thrown away. not printed, not counted, not rendered. if 2222 is already taken, that goroutine dies during startup and the SSH counter sits at zero forever — next to a live FTP counter, in the same header, looking exactly like a quiet day.</p>
<p>this is the worst failure mode a security tool can have: <strong>it is indistinguishable from success.</strong> the fix is three lines. send the error down the same channel, paint it red in the header, exit non-zero.</p>
<p><strong>2. the readme tells you to do something the code cannot do.</strong> it says:</p>
<pre><code class="language-bash">go build -o beetrap ./cmd/beetrap
sudo setcap cap_net_bind_service=ep ./beetrap
</code></pre>
<p>"to bind standard ports (22, 21, 80) without root". you will not bind port 22. the ports are integer literals inside <code>ui.Init()</code>. the capability gets granted to a binary that never asks for a privileged port. the fix is a flag, not a capability:</p>
<pre><code class="language-go">sshPort := flag.Int("ssh", 2222, "port to fake ssh on")
</code></pre>
<p>the intent in that readme section is not wrong, the code is just not connected to it. and the reason is visible in the history: four of the five commits in the repository are titled "Add files via upload". the files were written somewhere else and pasted in, and the readme arrived with them, describing a version of this program that does not exist.</p>
<p>smaller, but real: the accept loop does <code>if err != nil { continue }</code>. when a listener actually goes down, <code>Accept</code> returns that same error immediately and forever, and the goroutine spins at 100% cpu. it should <code>break</code>.</p>
<h2>what i would tell my past self</h2>
<p>the whole thing is one lesson wearing a network costume: <strong>you do not have to implement a protocol to learn from it.</strong> you send the right first line, you read the answer, you hang up. the entire intelligence of a honeypot lives in that first read, and everything after it — key exchange, authentication, a fake shell — is cost, liability and code i should be delighted not to have written.</p>
<p>and the second lesson is the boring one, which applies to every tool i have ever shipped: <strong>swallow an error and you have shipped something that lies to you by omission.</strong> the bind failure is a single <code>return</code>. it is the entire distance between a security tool and a screensaver.</p>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i wrote a packet sniffer to learn protocols, then lied about what it detects</title>
    <link>https://eneawork.it/blog/sniffcli-what-i-actually-built</link>
    <description><![CDATA[sniffcli counts packets to port 22 and calls it ssh brute-force detection. it cannot see a password, because ssh is encrypted. here is what it really does, and what building it taught me about reading my own code.]]></description>
    <content:encoded><![CDATA[<p>i built <a href="https://github.com/hnpf/sniffcli">sniffcli</a> because i wanted to understand what actually crosses my network. i had used <code>tcpdump</code> for years and remained completely unable to explain what a single line of its output meant.</p>
<p>the plan was simple: write the thing myself, read the packets, learn the protocols.</p>
<p>the plan worked. but not in the direction i expected.</p>
<h2>the honest part first</h2>
<p>the readme says the tool does "ssh brute-force detection". it does not. here is the entire feature:</p>
<pre><code class="language-go">if dstPort == 22 {
sshCounters[info.Source]++
if sshCounters[info.Source] > 3 {
info.IsAlert = true
info.Info = "POTENTIAL SSH BRUTE FORCE"
}
}
</code></pre>
<p>that is the whole thing. it counts packets to port 22, per source address, and shouts after the fourth one.</p>
<p>this cannot detect a brute-force attack. <strong>it cannot see a password, because ssh encrypts the entire session.</strong> the authentication exchange happens inside an encrypted tcp stream, and a passive sniffer sitting on the wire has no way in. even if i turned on <code>tcpdump -A</code> i would see binary ciphertext.</p>
<p>so what it actually detects is "something on my network talked to port 22 more than three times". that is not brute-force detection. that is a connection counter with a scary message attached.</p>
<h2>why i still kept the feature</h2>
<p>because it is not useless, it is just badly named. more than three connections to port 22 in a short window <em>is</em> a real signal — i have triggered it myself with <code>ssh</code> retries after a typo. and the same mechanism catches a scanner that is walking the network.</p>
<p>what i should have done is label it for what it is. "repeated ssh connections" is honest. "brute-force detection" promises something the code cannot deliver, and i wrote that promise.</p>
<p>the general lesson, which took me an embarrassingly long time to learn: <strong>a feature named after a threat implies a capability you may not have built.</strong> if your tool claims to detect attacks, the test is whether it can see the thing attackers actually send. mine cannot see any of it.</p>
<h2>what i learned about the protocols</h2>
<p>more than I expected, and none of it from documentation.</p>
<p>the layering is the part that finally clicked. a single frame is not one thing, it is a stack, and gopacket will hand you each layer separately:</p>
<pre><code class="language-go">if arpLayer := packet.Layer(layers.LayerTypeARP); arpLayer != nil {
// ...
} else if ipLayer := packet.Layer(layers.LayerTypeIPv4); ipLayer != nil {
// ...
} else if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
// ...
}
</code></pre>
<p>tcp is not "the packet". it is a layer that can exist without ip (some encapsulations), which is why the real code checks tcp and udp in their own branch after the address layers, instead of assuming a fixed order.</p>
<p><strong>"who has"</strong> is what an arp broadcast is: a machine asking the whole network "who has this ip, tell me your mac". it is the first thing you see if you start sniffing on a wired network, and it floods constantly because arp has no memory.</p>
<p><strong>multicast and discovery traffic</strong> is most of the noise. <code>224.0.0.0/24</code> for ipv4, <code>ff02::</code> for ipv6, plus mdns on 5353, ssdp on 1900, dhcp on 67/68. my first twenty minutes of sniffing were almost entirely this. the <code>-c</code> flag exists purely to hide it.</p>
<p><strong>a connection is bidirectional and a packet is not.</strong> the tool reads <code>tcp.SrcPort</code> and <code>tcp.DstPort</code> on every single frame, so the same conversation appears twice with the ports swapped. there is no built-in reassembly, and i do not write one. that is a real limitation and i would rather name it than pretend.</p>
<h2>the part i got wrong twice</h2>
<p><code>isLocalIP</code> decides whether a packet is "unusual outbound". it treats loopback, link-local, multicast and the three private ranges as local.</p>
<p>the bug: <strong>anything inside <code>192.168.0.0/16</code> counts as local, including other machines on my own network.</strong> so a device quietly exfiltrating to the nas next to it produces zero alerts. my rule says "not leaving the house", which is not the same as "not leaving my machine".</p>
<p>the honest fix is to compare against the actual interface address, not against a range of possible networks. i have not fixed it. it is written down here instead, which is the only reason this paragraph exists.</p>
<h2>the part i am happiest about</h2>
<p>a concurrent ui that does not stutter.</p>
<p>the sniffer runs in its own goroutine and pushes packets into the tea program:</p>
<pre><code class="language-go">p := tea.NewProgram(m, tea.WithAltScreen())
go startSniffing(iface, watchlist, p)
if _, err := p.Run(); err != nil {
// ...
}
</code></pre>
<p>the loop never touches the ui directly, it only sends messages. that is the whole reason the interface stays responsive during a traffic spike — a rule i already knew from the frontend side of my own site, and only appreciated properly once the ui was a terminal instead of the dom.</p>
<p>it is also why <code>HighPerformanceRendering</code> is explicitly set to <code>false</code>: this is a read-only log that the user scrolls, so letting bubbletea skip frames is cheaper than keeping them smooth.</p>
<p>and the small thing that made it pleasant to use: a device alias map, so <code>192.168.1.185</code> renders as "main rig" and a watched mac shows up in orange. security tools have a reputation for being unpleasant, and half of making one pleasant is letting the user see their own house in human words.</p>
<h2>what i would tell my past self</h2>
<p>open the file you wrote and count the lines that actually do what the name claims.</p>
<p>if the answer is three lines and they count something adjacent to the threat, you have written a connection counter. rename it before someone trusts it — including you, six months later, when you wonder why the alerts feel wrong.</p>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>material 3 in a personal site: what survived, what did not</title>
    <link>https://eneawork.it/blog/material-3-personal-site</link>
    <description><![CDATA[i shipped this site on the material 3 design system. six palette schemes, a real token layer, and the shape scale. here is what actually made it in, what i threw out, and the one thing that never worked at all.]]></description>
    <content:encoded><![CDATA[<p>this site runs on material 3. not inspired by it, not loosely based on it — the actual token names, the actual palette schemes, the actual shape language. i wanted to know whether a design system built for a phone app survives contact with a personal site, which has completely different priorities.</p>
<p>short answer: about 70% of it survived, and the 30% that died is the part i am most glad i dropped.</p>
<h2>what went in</h2>
<p><strong>the token layer</strong>, which is the real thing. <code>--primary-container</code>, <code>--on-primary-container</code>, <code>--surface-variant</code>, <code>--outline-variant</code> — those are the m3 names, not my own names, and every component consumes them rather than hardcoding a colour. this is the part that paid off the most, and not for aesthetic reasons.</p>
<p>it paid off because i switched the whole site from light to dark and back, and changed the accent, six times, while writing it. nothing broke. there is no <code>if (dark)</code> in my components. every colour in the stylesheet resolves through a variable, and the dark palette is a second set of values for the same names.</p>
<p><strong>all six palette schemes</strong>: tonal-spot, fidelity, content, neutral, expressive and fruit-salad. they are in the settings dialog and they all work, because they are what m3 calls them and not six hand-tuned themes.</p>
<p><strong>the shape scale.</strong> m3 has a defined corner radius ladder and cards sit at the top of it. my cards are <code>rounded-[2.4rem]</code>, buttons go fully rounded, nav pills are fully rounded. this is the single most recognisable m3 tell and it survives translation to a personal site without any trouble.</p>
<p><strong>the ripple</strong>, at the correct opacity:</p>
<pre><code>opacity: var(--ripple-hover-opacity, 0.08);
</code></pre>
<p>0.08 is the m3 state layer value. it feels almost too subtle, and that is correct — the point of the state layer is that you should not consciously notice it.</p>
<p><strong>ripple scope.</strong> this was the part i did not know i needed. the ripple only follows the pointer inside the element you actually pressed, not the whole page. it makes fast clicks feel precise instead of sloppy.</p>
<p><strong>the components that made sense as small web versions</strong>: switch, slider, text field, and two scrollbars, one for the window and one for panels. those are pure wins.</p>
<h2>what i threw out</h2>
<p><strong>elevation.</strong> m3 defines six levels, 0 through 5, with a specific shadow for each. i use <strong>none</strong> of them. there is not one elevation token in my stylesheet, and the only shadows in it are hover states on cards and buttons — nothing that describes how high a surface sits.</p>
<p>this was not laziness, it was a decision. m3's elevation model assumes opaque surfaces stacked on each other. this site has a translucent sidebar with a backdrop blur, and content scrolling underneath it. elevation is a depth metaphor for opaque planes; when the plane is see-through, the shadow lies about what is on top of what. so borders do the separating here, not shadows.</p>
<p>the m3 elevation values are good and i copied the spirit — borders at <code>--outline-variant</code>, radius at the top of the scale — without copying the tokens.</p>
<p><strong>the typography scale.</strong> m3 has five roles: display, headline, title, body, label, each with three sizes. i use roughly two of them, because a display role sized for a phone gets you about 57px, which is not a headline on a wide screen. the scale does not survive the jump from a 6-inch viewport to a 2560px one without being rescaled, and once you have rescaled it you are no longer using m3's scale, you are using yours that resembles it.</p>
<p>i kept the <em>idea</em> — a single expressive display face for the big moments, a monospace for the developer surfaces, plain sans for body — and threw away the specific ramp.</p>
<h2>the one that never worked</h2>
<p><strong>motion.</strong></p>
<p>m3 specifies durations and easing curves, and they are tuned for a phone: short, because a thumb is close to the screen. on a desktop pointer-driven site those same curves feel sluggish in a way that is hard to put into words. my eye is not where my thumb would be.</p>
<p>the bigger problem is that motion-heavy specs assume a reliable frame budget. they do not assume the frame budget might be zero.</p>
<p>i hit this hard. my sidebar collapse animation silently stopped working: the box stayed at full width, forever, with no error anywhere. the cause was not the animation logic but a layout projection fighting a width transition on the same element — two things both trying to own the box, and the loser never reached its final value. and the only reason i found it was measuring the computed width instead of trusting that the code "looked right".</p>
<p>i moved the width from a motion value into a plain css transition, and deleted the layout projection. the box now reaches its target within 60ms, measured, every time.</p>
<p>the lesson was not about framer motion. it was that a design system tells you what a finished state looks like and says nothing about how you get there when the frame budget collapses. for a phone app that assumption holds. for a web page running in a browser tab someone might have backgrounded, it does not.</p>
<h2>what i would keep if i started again</h2>
<p>the token layer, without hesitation. it is the part of m3 that is genuinely reusable outside android, and the reason is boring: it is a naming scheme that forces you to answer "what is this colour's role" instead of "what colour is it". that question is worth asking even if you never open the material docs.</p>
<p>the palette schemes and the shape scale, because they are free once the tokens exist.</p>
<p>the state layer opacity, because it is one number and it is right.</p>
<p>and the elevation, the type ramp and the motion curves, gone, with no regret — they are all assumptions about a context this site is not in.</p>
<p>the honest summary: m3 is a very good answer to "how does a native android app look and feel", and a decent starting point for a web design system, provided you are willing to treat it as a vocabulary rather than a specification. the tokens transfer. the pixel values do not.</p>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i found a 20 kb font, shipped the 2 mb one anyway</title>
    <link>https://eneawork.it/blog/font-subsetting-20kb-vs-2mb</link>
    <description><![CDATA[a variable font was downloading 1.9 mb to render latin text. there was a 20 kb subset sitting in the same folder. i measured both, and then i shipped the big one on purpose.]]></description>
    <content:encoded><![CDATA[<p>my site declares a font range of <code>U+0000-00FF</code> — basic latin, the first 256 code points. in practice that means accents, punctuation and not much else. and yet the file behind it was 1,945,520 bytes.</p>
<p>in the same folder there was a second file, <code>GoogleSansFlex-Latin.woff2</code>, weighing 20,076 bytes. same typeface. same designer. 1% of the size.</p>
<p>that is not a rounding error. that file was 0.99% of the size of the one being downloaded, so 99% of the weight of my text font was sitting there unused.</p>
<h2>the easy part</h2>
<p>swapping the filename in the <code>@font-face</code> rule is a ten-second edit. the page still works, the text still renders, and if you never look at it again you have saved 1.9 mb of bandwidth for every visitor, forever, on every page.</p>
<p>this is the part where a reasonable person stops and ships it.</p>
<h2>so i measured it first</h2>
<p>before touching anything i wrote a tiny glyph probe and ran it in a real browser, because the size difference is obvious but the useful question is whether the small file actually covers the characters the site uses. you can do it with no libraries at all:</p>
<pre><code>const load = (url) => new Promise((resolve) => {
  const f = new FontFace("Probe", `url(${url})`, { weight: "100 1000" });
  f.load().then(() => resolve("ok")).catch((e) => resolve("fail " + e.message));
  document.fonts.add(f);
});

await load("/fonts/GoogleSansFlex-Latin.woff2");
await document.fonts.ready;

const c = document.createElement("canvas").getContext("2d");
const width = (family, ch) => {
  c.font = `50px "${family}"`;
  return c.measureText(ch).width;
};
// a character the font does not have falls back, so it measures as the
// notdef box: compare against U+FFFF, which nothing maps to
const base = width("Probe", "\uFFFF");
const missing = [...text].filter((ch) => width("Probe", ch) === base);
</code></pre>
<p>the trick is the <code>U+FFFF</code> baseline. the notdef glyph has a real width, and any character the font lacks resolves to it, so an exact match against that width is your missing-glyph test.</p>
<p>i ran it over the whole range i care about — upper and lower case, digits, punctuation, and every accented letter this site needs to look correct in italian: <code>àèéìòùÀÈÉÌÒÙäöüßñç</code>, plus <code>€£¥©®™°·…</code>.</p>
<p>result: zero missing glyphs. the 20 kb subset covered everything, including characters well outside the declared range.</p>
<p>then i checked the real thing rather than trusting the probe:</p>
<pre><code>c.font = '50px "Google Sans Flex Local", monospace';
// 264.7 px — not the 300 px the fallback produces, so the real font is live
</code></pre>
<pre><code>const rendered = width('"Google Sans Flex Local", monospace', "Handgloves");
const fallback = width("monospace", "Handgloves");
</code></pre>
<p>the two differ, so the subset was genuinely active and not silently falling back. by every measurement available to me, the swap was correct.</p>
<h2>and then i reverted it</h2>
<p>because the numbers were not the whole problem. two things the measurement could not see:</p>
<ul>
<li><strong>the shapes are not identical.</strong> both files are cut from the same family, but the subset's outlines are not the variable font's outlines at the default axis settings. my page's headings are the largest text on the site, and they were visibly smaller and lighter than what i had designed against.</li>
<li><strong>the variable axes were gone.</strong> the file i shipped is a variable font, and the site sets <code>wght</code> and <code>wdth</code> through <code>font-variation-settings</code>. a subset cut for latin does not necessarily carry those axes. losing <code>wght</code> means every <code>font-black</code> in the stylesheet silently falls back to whatever the static weight happens to be.</li>
</ul>
<p>a font-size regression on the hero heading is not something a byte counter is going to tell you about. i saw it, i did not like it, and i put the 1.9 mb file back.</p>
<h2>what i actually kept</h2>
<p>the number that made the difference is not the size. it is the preload:</p>
<pre><code>&#x3C;link rel="preload" href="/fonts/GoogleSansFlex-Variable.woff2"
      as="font" type="font/woff2" crossorigin />
</code></pre>
<p>the site is text-first, so that font is on the critical path. preloading it starts the download during html parsing instead of waiting for the css to arrive and be parsed, which removes a full round trip from the first paint.</p>
<p><code>crossorigin</code> is the part people forget. fonts are always fetched in cors mode, even same-origin, so a preload without that attribute is discarded by the browser and the file gets fetched a second time anyway. you get the worst of both: the preload cost and no benefit.</p>
<h2>the honest conclusion</h2>
<p>1.9 mb is too much for a text font and i know how to make it 20 kb. the correct fix is not to hand-pick a latin subset — it is to build the subset myself with <a href="https://fonttools.readthedocs.io/en/latest/subset/index.html"><code>pyftsubset</code></a>, keeping the axis ranges the stylesheet actually uses, and to check the rendered result against the original before shipping.</p>
<p>until that exists, the 2 mb file stays and the site is honest about it.</p>
<p>the useful part of this exercise was not the swap. it was noticing that the two files had been sitting in the same folder the whole time, and that i had no idea which one the browser was actually downloading. now i check <code>transferSize</code> on a reload, which reads <code>0</code> for every asset the browser already holds. that number is the only honest performance report i have ever used.</p>]]></content:encoded>
    <pubDate>Sat, 03 Oct 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the internet made me weirdly good at knowing useless things</title>
    <link>https://eneawork.it/blog/internet-useless-info</link>
    <description><![CDATA[i never set out to learn the difference between init unit types, or how to identify a country from a flag that has been cropped in half. and yet here...]]></description>
    <content:encoded><![CDATA[<p>i never set out to learn the difference between init unit types, or how to identify a country from a flag that has been cropped in half. and yet here we are.</p>
<p>you open one tab for a definition of god knows what, and two hours later it is 2 am and you can explain, with total confidence, why c lets you shoot yourself in the foot.</p>
<p>the modern internet does not really give you education. what it gives you is random hyper-specific facts with absolutely no context attached. and somehow that sticks.</p>
<h2>what my brain ended up with</h2>
<ul>
<li><strong>flags and geography:</strong> i can tell romania and chad apart at a glance. yes i know the difference. chad is slightly darker, or it is indonesia or monaco, and monaco is slightly shorter. do not ask me why any of this is useful.</li>
<li><strong>linux and command lines:</strong> a handful of bash commands that probably fix a bug exactly three people have ever had, one of whom is me. i learned nothing from it.</li>
<li><strong>security history:</strong> proxy conflicts, write-ups, threat actors. i could not tell you my own schedule tomorrow, but ask me about a 2014 cve and i will spit it out.</li>
</ul>
<h2>is it useless?</h2>
<p>yes, and no, and mostly yes.</p>
<table>
<thead>
<tr>
<th>category</th>
<th>value in practice</th>
<th>value in conversation</th>
</tr>
</thead>
<tbody>
<tr>
<td>geography</td>
<td>helps you win arguments</td>
<td>pure entertainment</td>
</tr>
<tr>
<td>tech</td>
<td>fixes things occasionally</td>
<td>makes you sound clever</td>
</tr>
<tr>
<td>history</td>
<td>almost none</td>
<td>unreasonably good</td>
</tr>
</tbody>
</table>
<p>the honest answer is that none of this was planned. it was not for school, nobody was paying me for it. but i can talk about the most random topic for an unreasonable amount of time, and as an italian kid with too much internet that apparently counts for something.</p>]]></content:encoded>
    <pubDate>Sat, 19 Sep 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>one artist quietly changed how i listen to music</title>
    <link>https://eneawork.it/blog/2slimey-changed-how-i-listen</link>
    <description><![CDATA[i make music as rxenea. rage, trap, a bit of noise, things that are loud on purpose. and i thought i knew exactly what i liked, which is a dangerous...]]></description>
    <content:encoded><![CDATA[<p>i make music as rxenea. rage, trap, a bit of noise, things that are loud on purpose. and i thought i knew exactly what i liked, which is a dangerous thing to think as a producer.</p>
<p>then i stumbled across an artist and realised i had been listening to the same five things on repeat for years, not because i loved them, but because they were easy.</p>
<p>what changed was not the genre. it was the detail.</p>
<p>i started listening to how the low end sits in the mix instead of just how loud it is. i noticed when a hi-hat is pushed slightly off the grid. i started hearing arrangement, not just sound. once you hear that, you cannot unhear it, and every single thing i had made before suddenly had a problem i had never noticed.</p>
<p>the practical part, since i make music:</p>
<ul>
<li>stop mixing by genre, mix by reference</li>
<li>pick one track and ask "what does this do that mine does not"</li>
<li>the boring answer is usually arrangement or the low end, never the vocal</li>
<li>write the idea down immediately, before the coffee wears off</li>
</ul>
<p>i am not saying i made anything good because of this. i am saying i made anything <em>finished</em> because of this, which matters more.</p>]]></content:encoded>
    <pubDate>Tue, 11 Aug 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i turned off motion on my own site and felt nothing</title>
    <link>https://eneawork.it/blog/stop-moving-my-screen</link>
    <description><![CDATA[i spent a week making this site move. springs, ripples, sliding panels, everything. and then i spent one evening using it with motion disabled, which...]]></description>
    <content:encoded><![CDATA[<p>i spent a week making this site move. springs, ripples, sliding panels, everything. and then i spent one evening using it with motion disabled, which is a setting i built months ago and never actually turned on.</p>
<p>the honest result: nothing was missing. nothing felt broken. i just read the whole thing faster.</p>
<p>this is not a new observation. designers have known it for years. what is new to me is having built both sides of it. the motion is genuinely nice. it is also, for a lot of people, a tax they pay on every visit to enjoy a feature they did not ask for.</p>
<p>so now there is a global toggle in the settings, and it defaults to respecting <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion"><code>prefers-reduced-motion</code></a>, which is what <a href="https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html">WCAG 2.3.3</a> asks for. the springs are still there for the people who want them.</p>
<p>the design lesson i am taking from this is small and annoying: <strong>as a ux designer, the motion is not the feature. the task is the feature.</strong> motion is seasoning, and a restaurant that puts seasoning on everything tastes like nothing.</p>]]></content:encoded>
    <pubDate>Sat, 04 Jul 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i wrote a post about leaving vercel for cloudflare. i was already on firebase.</title>
    <link>https://eneawork.it/blog/leaving-vercel</link>
    <description><![CDATA[the post i wrote about moving this site to cloudflare pages was wrong. i never moved it. it has been on firebase hosting the whole time, and here is what that actually means...]]></description>
    <content:encoded><![CDATA[<p>the post i wrote about leaving vercel for cloudflare pages was wrong.</p>
<p>i did not move this site. it has been on <strong>firebase hosting</strong> the entire time, and the reason nobody caught the mistake is that the files claiming otherwise were sitting right there in the repo: a <code>wrangler.toml</code>, a <code>public/_headers</code>, and a <code>functions/</code> directory full of cloudflare pages functions. all three described a hosting setup that was never the one answering traffic.</p>
<h2>how i found out</h2>
<p>i went to look at my own response headers instead of trusting the repo. <code>vary: x-fh-requested-host</code> — <code>fh</code> is firebase hosting. then i diffed the html the domain was actually serving against the site i believed was live, and they were byte identical, both of them an april build that predated most of this site.</p>
<p>the project also holds four hosting sites. the project default and the one behind the real domain were returning the exact same 2713 bytes of html. a plain <code>firebase deploy</code> without an explicit target would have overwritten the wrong one, silently, with no error and no warning.</p>
<h2>what is actually running</h2>
<ul>
<li>static hosting for the whole app, with a rewrite mapping every client route to one html document</li>
<li>cache headers written down in a file i can read, instead of a rules engine that merges them with commas</li>
<li>one command to deploy, from a config that fits on a screen</li>
</ul>
<h2>what i gave up, honestly</h2>
<p>the <code>functions/</code> directory was cloudflare pages functions. firebase hosting is static, so they do not run. which means:</p>
<ul>
<li><code>/ping</code>, <code>/json</code>, <code>/help</code> and the terminal view answer with the page html instead of text</li>
<li>the guestbook endpoint returns nothing useful</li>
<li>the per-post <code>og:</code> rewriting for bots never executes</li>
</ul>
<p>i can port those to cloud functions when i actually need them. i have not, and i would rather write that here than let a <code>200</code> with the wrong body keep looking like it works.</p>
<h2>the actual lesson</h2>
<p>not that firebase is better than cloudflare. it is not, and i am not claiming it is. the lesson is that a config file in your repo is a <em>claim</em> about your infrastructure, and nothing verifies it. i knew where this site lived only because i read the headers instead of reading the repository.</p>
<p>a comment is not a deployment.</p>]]></content:encoded>
    <pubDate>Sun, 24 May 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>your data is not on the internet. it is on someone else's computer.</title>
    <link>https://eneawork.it/blog/data-sovereignty</link>
    <description><![CDATA[the phrase "the cloud" is doing an enormous amount of marketing work. what it actually means is: a computer, in a building, owned by a company, that...]]></description>
    <content:encoded><![CDATA[<p>the phrase "the cloud" is doing an enormous amount of marketing work. what it actually means is: a computer, in a building, owned by a company, that you do not control.</p>
<p>that is not automatically bad. the alternative for most people is worse. but it is worth being clear about what you are actually renting.</p>
<ul>
<li>you do not have the data, you have a <em>view</em> of it</li>
<li>you do not have the server, you have an <em>agreement</em> about the server</li>
<li>the interface is designed to make the distinction invisible</li>
</ul>
<p>what changed my mind on this was shipping a guestbook. i wrote thirty lines of code and suddenly had to answer real questions: where does this row actually live, what happens if the project is deleted tomorrow, who can read it, and what do i tell people who ask me to delete something.</p>
<h2>what i actually did</h2>
<ul>
<li>moved the database to something i could reason about</li>
<li>kept the data model simple enough that i could export it in one command</li>
<li>made the export path a real feature, not an escape hatch nobody tested</li>
<li>stopped pretending the abstraction was free</li>
</ul>
<p>sovereignty is not paranoia. it is just knowing where your files are.</p>]]></content:encoded>
    <pubDate>Sat, 23 May 2026 22:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i cleaned up my linux setup and immediately broke it</title>
    <link>https://eneawork.it/blog/ewaste-1-fr</link>
    <description><![CDATA[every few months i do this to myself: i look at my desktop, decide it is a mess, and reorganise. i have never once been satisfied with the result....]]></description>
    <content:encoded><![CDATA[<p>every few months i do this to myself: i look at my desktop, decide it is a mess, and reorganise. i have never once been satisfied with the result.</p>
<p>this time i went from a heavily customised setup to something boring. no compositor tricks, no three competing bars, no wallpaper script that takes four seconds to start. just ubuntu, gnome, and one tool.</p>
<p>and then, of course, i broke it within a week.</p>
<p>what broke, in order: a keybinding i forgot i had changed, a screensaver that decided gnome was a kiosk, and an autostart entry that had been quietly fighting another one for months and finally won.</p>
<h2>what i took from it</h2>
<ul>
<li><strong>write down what you changed</strong> before you change it, not after</li>
<li><strong>the thing you removed was probably doing something</strong> you had forgotten about</li>
<li><strong>a clean desktop you are scared to touch is not clean, it is abandoned</strong></li>
<li><strong>most customisation is a solution to a problem you stopped having</strong></li>
</ul>
<p>i am not going back to the chaotic version. but i am keeping a written list of the setup, which would have saved me the entire weekend i just lost.</p>]]></content:encoded>
    <pubDate>Sat, 14 Mar 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the site redesign, and what i got wrong the first time</title>
    <link>https://eneawork.it/blog/feb-11-update</link>
    <description><![CDATA[i rebuilt the front of this site and, for the first time in my life, started from the design system instead of from a screenshot. google's material 3...]]></description>
    <content:encoded><![CDATA[<p>i rebuilt the front of this site and, for the first time in my life, started from the design system instead of from a screenshot.</p>
<p>google's material 3 expressive is the current reference. the research behind it claims a significant lift in how "expressive" a product feels, which is exactly the kind of thing i would have dismissed a year ago and would now defend unpleasantly.</p>
<h2>what changed</h2>
<ul>
<li>a real token layer for colour, shape and motion instead of hardcoded values everywhere</li>
<li>every interactive element rebuilt on one component, so the focus ring and the press animation cannot drift apart</li>
<li>typography with an actual scale instead of "big, bigger, biggest"</li>
<li>light and dark built from the same source, not written twice</li>
</ul>
<h2>what i got wrong</h2>
<p>i treated the expressive style as a look to apply. it is not. the motion is supposed to carry meaning, and the first version had springs everywhere and a flat hierarchy underneath. it looked expensive and communicated nothing.</p>
<p>second mistake: i designed desktop first. mobile is where the effort should start, and i still do not believe this instinctively.</p>]]></content:encoded>
    <pubDate>Tue, 10 Feb 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>search on the web got worse, so i wrote my own</title>
    <link>https://eneawork.it/blog/search-is-mid</link>
    <description><![CDATA[i used search to find things. now i use search to be mildly annoyed. the problems are not new, they just got better funded: so i built search into...]]></description>
    <content:encoded><![CDATA[<p>i used search to find things. now i use search to be mildly annoyed.</p>
<p>the problems are not new, they just got better funded:</p>
<ul>
<li><strong>answer containers that answer a different question</strong> than the one you typed</li>
<li><strong>ai summaries that are confident and wrong</strong>, which is the worst possible combination</li>
<li><strong>a wall of sponsored results</strong> before the actual results</li>
<li><strong>ten pages of the same article</strong>, scraped and rehosted</li>
</ul>
<p>so i built search into this site. it is a small text index over my own posts, projects and pages. it is worse than a real search engine and better in one specific way: it only ever returns things i wrote.</p>
<p>i am not claiming to have solved search. i am claiming that the useful part of search is a good index and honest results, and that a surprising amount of the rest is someone trying to sell you something.</p>
<p>the whole thing is a few hundred lines. the hard part was not the matching, it was deciding what deserved to be in the index at all.</p>]]></content:encoded>
    <pubDate>Mon, 09 Feb 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the linux theming rabbit hole, and how i got out</title>
    <link>https://eneawork.it/blog/unw-upw-is-a-bible-lol</link>
    <description><![CDATA[if you have ever spent a full evening making a status bar pixel-perfect against a screenshot of someone else's status bar, this one is for you. the...]]></description>
    <content:encoded><![CDATA[<p>if you have ever spent a full evening making a status bar pixel-perfect against a screenshot of someone else's status bar, this one is for you.</p>
<p>the pipeline went like this: dots, then a bar, then a bar with modules, then modules with rounded corners, then rounded corners with per-module transparency, then a script that generated the gradient of the modules, then a second script that generated the script.</p>
<p>somewhere in there i lost an entire weekend to a compositor bug that turned out to be a rounding difference between two versions of something.</p>
<h2>what actually fixed it</h2>
<p>i wrote down the one rule that mattered and deleted everything else:</p>
<ul>
<li>the bar exists to show information, not to show taste</li>
<li>a module that does not change every few seconds does not need animation</li>
<li>the screenshot i was chasing was made by someone who has more free time than me</li>
</ul>
<p>this site has a theme system. it has presets, an accent colour, and a palette picker. and it does not have a single script that generates another script.</p>
<p>the theming on my desktop is now boring. that is not a loss. it means i stopped using my own desktop as a project.</p>]]></content:encoded>
    <pubDate>Mon, 02 Feb 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i made a gui for the one linux command i used every day</title>
    <link>https://eneawork.it/blog/sysdupd</link>
    <description><![CDATA[every linux machine i touched used the same command: update the system. it is powerful, it is long, and i could never remember the flags in the right...]]></description>
    <content:encoded><![CDATA[<p>every linux machine i touched used the same command: update the system. it is powerful, it is long, and i could never remember the flags in the right order.</p>
<p>so i wrote one button for it.</p>
<h2>the honest scope</h2>
<p>it is a small gui around a package manager. it shows the available updates, lets you pick, shows a log, and tells you when it wants a reboot. that is it.</p>
<h2>what i actually learned</h2>
<ul>
<li><strong>the hard part is never the button.</strong> it is the failure states: no network, a held package, a mirror that is down, a lock file left behind by a crash</li>
<li><strong>never run the update from a gui without a log.</strong> if the user is going to press one button, that button owes them a way to read what happened</li>
<li><strong>"partially upgraded systems" is a real state</strong> and your ui has to have a screen for it</li>
<li><strong>the gui is not the product.</strong> the transaction is the product, the gui is the convenience</li>
</ul>
<h2>the part i am proudest of</h2>
<p>it refuses to run as root silently. if the update needs privileges, it tells you exactly what it needs and why, instead of asking for everything up front.</p>
<p>it is not a big project. it is the first one i finished, and i have started about nine others.</p>]]></content:encoded>
    <pubDate>Thu, 15 Jan 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the phone i actually bought, and why the spec sheet lied to me</title>
    <link>https://eneawork.it/blog/pixel-10-review</link>
    <description><![CDATA[i do not review phones professionally. i bought one, used it for two months, and am writing about the gap between the number and the experience. the...]]></description>
    <content:encoded><![CDATA[<p>i do not review phones professionally. i bought one, used it for two months, and am writing about the gap between the number and the experience.</p>
<h2>what sold me</h2>
<p>the camera, obviously. and the fact that android still gives you things apple has been quietly removing: a proper file manager, real customisation, an actual settings app with everything in it.</p>
<h2>what the spec sheet got wrong</h2>
<ul>
<li><strong>the battery number is a laboratory result.</strong> my day is not a laboratory. the difference between the two is the whole review.</li>
<li><strong>the display resolution stops mattering</strong> the moment you read anything long on it, which is most of the day</li>
<li><strong>"processor generation" tells you nothing about thermals</strong>, and thermals are the whole game</li>
<li><strong>the charger in the box is a decision about your evenings</strong>, not about the phone</li>
</ul>
<h2>what i would change</h2>
<p>the software support timeline, honestly. not the hardware. the hardware is fine, it is more than fine. it is the part where the manufacturer decides how long you are allowed to keep it, and that has nothing to do with how good the phone is.</p>
<p>if you care about that, buy the phone with the longest support you can find and stop thinking about the spec sheet. it is the only number that will still matter in three years.</p>]]></content:encoded>
    <pubDate>Thu, 15 Jan 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>i used arch for years and i moved to ubuntu</title>
    <link>https://eneawork.it/blog/why-i-actually-use-arch</link>
    <description><![CDATA[this is a post i did not expect to write. for years i was one of those people who explains arch to anyone who stands still long enough. and then,...]]></description>
    <content:encoded><![CDATA[<p>this is a post i did not expect to write. for years i was one of those people who explains arch to anyone who stands still long enough.</p>
<p>and then, like everyone eventually does, i moved to ubuntu and gnome, and everything kept working.</p>
<h2>why i moved</h2>
<ul>
<li><strong>i was maintaining a desktop, not using one.</strong> the week i moved, my maintenance went from a few hours a month to about ten minutes, and i reclaimed that time for actual work</li>
<li><strong>my hardware is not interesting.</strong> i did not need a bleeding edge kernel, i needed my laptop to not beep at me</li>
<li><strong>gnome is a complete desktop</strong>, not a foundation you assemble on weekends</li>
<li><strong>i got tired of the aesthetic.</strong> rolling release is a choice, and i was making it for reasons that stopped being about me</li>
</ul>
<h2>what i kept</h2>
<p>i kept hyprland for the days i want the compositor, i kept a handful of tools that only exist because i built them, and i kept the habit of reading logs when something breaks. you do not get that habit for free.</p>
<h2>the honest conclusion</h2>
<p>arch was never better. it was mine. once i realised i was keeping it because it was mine rather than because it worked for me, the argument was over.</p>
<p>use whatever you can maintain. that is the entire opinion.</p>]]></content:encoded>
    <pubDate>Tue, 13 Jan 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the internet is getting dumber and i think i know why</title>
    <link>https://eneawork.it/blog/the-internet-is-dead</link>
    <description><![CDATA[not dead. worse in a specific way: automated, and nobody noticing. here is the loop i have watched for the past year: the result is content optimised...]]></description>
    <content:encoded><![CDATA[<p>not dead. worse in a specific way: automated, and nobody noticing.</p>
<p>here is the loop i have watched for the past year:</p>
<ul>
<li>something gets posted, usually with a blue check and no history</li>
<li>five accounts with generated names reply within minutes</li>
<li>those replies get scraped and summarised somewhere nobody chose to put them</li>
<li>someone who writes for a living reads the summary</li>
</ul>
<p>the result is content optimised for being machine-consumed, written by things that only know what the previous layer said. it is not stupid, exactly. it is <em>circular</em>, and it is presented as a conversation.</p>
<h2>why this is bad for me specifically</h2>
<p>i make things. small things, mostly. and the only honest way to make a good small thing is to spend a long time on something almost nobody sees. that entire economy is invisible to a system that measures replies.</p>
<h2>what i do about it</h2>
<ul>
<li><strong>i stopped optimising for reach.</strong> this blog has a search index and an rss feed, and that is deliberate</li>
<li><strong>i write for one person,</strong> usually me, six months ago</li>
<li><strong>i keep the raw source somewhere i can read it.</strong> if the platform goes, the writing is still mine</li>
</ul>
<p>the internet is not dead. it is just no longer a place where being interesting is enough. you have to be findable, and findable is a different skill from interesting.</p>]]></content:encoded>
    <pubDate>Sun, 11 Jan 2026 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>what i actually think about using ai to build things</title>
    <link>https://eneawork.it/blog/my-take-on-ai</link>
    <description><![CDATA[i use ai tools. i have opinions about that. here they are, without the part where i pretend to be balanced. i use it the way i use a reference...]]></description>
    <content:encoded><![CDATA[<p>i use ai tools. i have opinions about that. here they are, without the part where i pretend to be balanced.</p>
<h2>where it is genuinely good</h2>
<ul>
<li>getting from "i do not know how to express this" to a first draft that runs</li>
<li>reading codebases that are not mine</li>
<li>the boring translation between two things that should be the same</li>
<li>as a rubber duck that answers back</li>
</ul>
<h2>where it is genuinely bad</h2>
<ul>
<li><strong>it removes the part where you struggle</strong>, and the struggle is where the understanding comes from</li>
<li><strong>it produces confident wrong answers</strong> with the exact shape of right ones</li>
<li><strong>it launders other people's code</strong> through a model that will not tell you whose</li>
<li><strong>the output has no memory of your constraints.</strong> you have to re-explain every time</li>
</ul>
<h2>my actual rule</h2>
<p>i use it the way i use a reference implementation: to get unstuck and to be corrected. anything i ship, i could explain line by line, or it does not ship.</p>
<p>the tell for me is when i cannot explain why the code works. at that point i did not write anything, and i should not be putting my name on it.</p>]]></content:encoded>
    <pubDate>Sat, 15 Nov 2025 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>nothing has gone down in a year. here is why that is not luck</title>
    <link>https://eneawork.it/blog/keeping-it-live</link>
    <description><![CDATA[one of the projects i maintain has been up continuously for over a year. people keep asking if i got lucky. partly i did. but mostly it is three...]]></description>
    <content:encoded><![CDATA[<p>one of the projects i maintain has been up continuously for over a year. people keep asking if i got lucky.</p>
<p>partly i did. but mostly it is three boring decisions i made early and never revisited.</p>
<h2>1. boring hosting</h2>
<p>static files where possible, a small runtime where necessary, a database that is a file. every clever thing i added to the stack got removed within a month because it added a way to fail.</p>
<h2>2. the boring part is observable</h2>
<p>the only thing i built for myself that i would call infrastructure is a health check i actually look at. not a dashboard i forgot about. a check, a log line, and me reading it.</p>
<h2>3. no clever deployments</h2>
<p>one branch, one build command, one place it runs. no canary, no blue-green, no matrix of environments. the entire deployment is short enough to read in one breath, which means when it breaks i understand why.</p>
<h2>the honest part</h2>
<p>the site went down anyway, twice, for reasons that had nothing to do with sophistication. a certificate, and my own bad configuration during a migration.</p>
<p>nothing clever would have saved either. reading the log would have.</p>]]></content:encoded>
    <pubDate>Sat, 15 Nov 2025 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>the reason i open source things is not kindness</title>
    <link>https://eneawork.it/blog/why-love-open-source</link>
    <description><![CDATA[people assume open source is generosity. it is not. for me it is self-interest with better branding. it is not a strategy. i have no interest in a...]]></description>
    <content:encoded><![CDATA[<p>people assume open source is generosity. it is not. for me it is self-interest with better branding.</p>
<h2>the real reasons</h2>
<ul>
<li><strong>i cannot fix my own tools alone.</strong> i shipped a project with exactly one bug report, from someone i do not know, in a language i had not used before. that was the entire value and it cost me nothing</li>
<li><strong>being wrong in public is free education.</strong> every issue i never got was a mistake i got to keep</li>
<li><strong>the code is the documentation.</strong> explaining something twice costs more than writing it down once</li>
<li><strong>i use everything i publish.</strong> the portfolio site, the terminal, the markdown renderer, the tools. none of them have a "real" version</li>
</ul>
<h2>what it is not</h2>
<p>it is not a strategy. i have no interest in a business, and the moment i start treating publication as marketing, the whole thing becomes a performance.</p>
<h2>the part nobody mentions</h2>
<p>maintaining is the job. the writing takes an afternoon, the answering takes a year. i am fine with that because i want the code to exist outside my head, but anyone who says "just open source it" has not yet read the issues.</p>]]></content:encoded>
    <pubDate>Tue, 11 Nov 2025 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>why i take security seriously as a designer</title>
    <link>https://eneawork.it/blog/importance-of-cybersecurity</link>
    <description><![CDATA[most people in my field treat security as the part someone else handles. the brief says make it beautiful, another document says make it safe, and...]]></description>
    <content:encoded><![CDATA[<p>most people in my field treat security as the part someone else handles. the brief says make it beautiful, another document says make it safe, and both of those conversations happen without me in the room.</p>
<p>that is wrong, and here is the part where design is actually the skill.</p>
<ul>
<li><strong>most security problems are interface problems.</strong> people reuse passwords because the login form is hostile. people ignore warnings because the warning looks like an ad. people click the thing because the button was honest about what it would do and the other one lied</li>
<li><strong>defaults are design.</strong> the path of least resistance is the most powerful thing on the screen</li>
<li><strong>an error message is a security control.</strong> a vague error prevents a fix; a precise one teaches the user what went wrong</li>
</ul>
<p>the specific things i changed on my own sites because of this:</p>
<ul>
<li>the settings that weaken security are grouped, labelled with the cost, and never one click from the safe option</li>
<li>anything destructive asks for a name, not just a confirmation</li>
<li>the import and export paths are symmetric, because data you cannot get back is data you have lost</li>
</ul>
<p>i am not a security engineer. i am the person who decides what the user is offered, and that is enough to lose data.</p>]]></content:encoded>
    <pubDate>Wed, 05 Nov 2025 23:00:00 GMT</pubDate>
  </item>

  <item>
    <title>how i actually got into security without a degree</title>
    <link>https://eneawork.it/blog/cybersec-101</link>
    <description><![CDATA[i am italian, i did not study computer science, and for a long time i thought security was something other people were allowed to do. it is not. here...]]></description>
    <content:encoded><![CDATA[<p>i am italian, i did not study computer science, and for a long time i thought security was something other people were allowed to do. it is not. here is the path that worked for me.</p>
<h2>start with the boring stuff</h2>
<ul>
<li>learn how the network actually moves, not the tool that watches it</li>
<li>read the source of something you use daily until you find something that surprises you</li>
<li>learn one scripting language properly enough to automate your own boredom</li>
</ul>
<h2>then do damage, but only to yourself</h2>
<p>i broke my own router, my own site, and my own laptop in that order. each time i wrote down exactly what i assumed, then exactly why the assumption was wrong. that list is worth more than any course.</p>
<p>the habit that matters is writing things down. an exploit you cannot explain is a party trick, not knowledge.</p>
<h2>what i would tell past me</h2>
<ul>
<li><strong>do not start with a framework.</strong> start with the protocol</li>
<li><strong>read one advisory end to end.</strong> you learn more from one real thing than from a list of topics</li>
<li><strong>write your own tooling.</strong> a script you wrote will teach you what a tool hides</li>
<li><strong>italians are not behind.</strong> the internet does not care where you learned from, only whether you did</li>
</ul>
<p>i still have a lot to learn. i am just no longer waiting to be told i am allowed to.</p>]]></content:encoded>
    <pubDate>Sun, 06 Jul 2025 22:00:00 GMT</pubDate>
  </item>
</channel>
</rss>
