{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "dout.dev - Latest Posts",
  "home_page_url": "https://dout.dev/",
  "feed_url": "https://dout.dev/feed.json",
  "description": "Vanilla-first static blog with WCAG 2.2 AA accessibility",
  "language": "en",
  "authors": [
    {
      "name": "Emiliano \"pixu1980\" Pisu",
      "url": "https://dout.dev"
    }
  ],
  "items": [
    {
      "id": "https://dout.dev/posts/2026-08-12-the-bullshit-doesnt-scale.html",
      "url": "https://dout.dev/posts/2026-08-12-the-bullshit-doesnt-scale.html",
      "title": "The Bullshit Doesn't Scale",
      "summary": "Before anyone gets creative with their parsing: the subject of this analysis and the author are two entirely separate entities.",
      "content_html": "<h2 id=\"before-anyone-gets-creative-with-their-parsing-the-subject-of-this-analysis-and-the-author-are-two-entirely-separate-entities\" tabindex=\"0\" data-toc-anchor=\"true\">Before anyone gets creative with their parsing: the subject of this analysis and the author are two entirely separate entities.</h2>\n<p>Consider this a bug report.</p>\n<p>Except this time, the bug had access to the roadmap.</p>\n<p>I'm talking about that fascinating professional category capable of turning incompetence into confidence, bullshit into strategy, and bluffing into a career.</p>\n<p>And the best part is that, for a while, it actually works.</p>\n<p>You can bluff.</p>\n<p>You can sell confidence instead of competence.</p>\n<p>You can fill meetings with words, turn processes into theater, and bury problems under explanations complicated enough to make it look like the blame always belongs somewhere else.</p>\n<p>You can even convince yourself that managing people means manipulating the perception of what's happening, instead of taking responsibility for what you do.</p>\n<h2 id=\"for-a-while-it-can-work\" tabindex=\"0\" data-toc-anchor=\"true\">For a while, it can work.</h2>\n<p>The problem is that reality has this annoying habit of producing measurable results.</p>\n<p>And when incompetence, arrogance, and a certain talent for snake-oil salesmanship start leaving enough wreckage behind, even the most patient companies eventually do the math.</p>\n<p>Maybe after spending money.</p>\n<p>Maybe after burning energy.</p>\n<p>Maybe after wasting time and losing good people.</p>\n<p>But eventually, they do.</p>\n<p>And that's when the castle of storytelling finally collides with that ancient, decidedly non-agile concept we call \"consequences\".</p>\n<p>Some call it accountability.</p>\n<p>Some call it corporate natural selection.</p>\n<p>Romantically, I prefer to call it <code>karma</code>.</p>\n<p>Because you can pretend to know how to do a job for a very long time.</p>\n<p>The difficult part is keeping the act going once the people around you start looking at results instead of slides.</p>\n<p>And thankfully, there are still companies healthy enough to realize, even if a little late, that sometimes the best way to improve a product isn't to add a feature.</p>\n<p>It's to remove a bug.</p>\n<h2 id=\"best-wishes\" tabindex=\"0\" data-toc-anchor=\"true\">Best Wishes</h2>\n<p>To all you Senior Email Forwarders out there, I wish you nothing but the best.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-08-12-the-bullshit-doesnt-scale.png",
      "date_published": "2026-08-12T00:00:00.000Z",
      "tags": [
        "culture",
        "opinion",
        "leadership",
        "engineering"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-08-10-nodejs-concurrency-event-loop.html",
      "url": "https://dout.dev/posts/2026-08-10-nodejs-concurrency-event-loop.html",
      "title": "Node.js Concurrency Is Not a Mystery - You Just Never Learned the Event Loop",
      "summary": "Your server is slow because you are fighting a runtime you never learned",
      "content_html": "<h2 id=\"your-server-is-slow-because-you-are-fighting-a-runtime-you-never-learned\" tabindex=\"0\" data-toc-anchor=\"true\">Your server is slow because you are fighting a runtime you never learned</h2>\n<p>Every Node.js developer has a story about the endpoint that worked fine in development and collapsed in production. The CPU spiked. The event loop stalled. Health checks started failing. And someone on the team said the words that have launched a thousand misdiagnoses: \"Node is single-threaded, we need more servers.\"</p>\n<p>Node.js is not single-threaded. Your JavaScript runs on one thread. The process uses several. Conflating the two is not a minor technicality - it is the root cause of most Node performance incidents I have seen in fifteen years of reviewing backend code.</p>\n<p>The <a href=\"https://blog.openreplay.com/the-event-loop-worker-threads-and-concurrency-in-node-js/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">OpenReplay Team wrote a precise, layered breakdown</a> of how the event loop, libuv thread pool, and worker threads actually work. What follows builds on their technical blueprint and adds what every senior backend engineer should internalize: <strong>the mental model that makes the right concurrency tool obvious before you touch a config file.</strong></p>\n<h2 id=\"stop-saying-node-is-single-threaded\" tabindex=\"0\" data-toc-anchor=\"true\">Stop saying \"Node is single-threaded\"</h2>\n<p>Here is the sentence you need to memorize: <strong>Node.js executes your JavaScript on one thread. The runtime is not single-threaded. libuv maintains a pool of background threads, and the operating system handles network I/O on Node's behalf.</strong> Treat \"single-threaded\" as a statement about where your code runs, not about the process as a whole. Get this wrong and you will reach for cluster when you need a worker, or bump <code>UV_THREADPOOL_SIZE</code> when you need to refactor a blocking function.</p>\n<p>The distinction that matters is between concurrency and parallelism:</p>\n<ul>\n<li data-reveal=\"\"><strong>Concurrency</strong>: multiple tasks make progress over the same period by interleaving on a shared resource. A single-core machine running Node is concurrent but not parallel. The event loop rapidly switches between in-flight operations, but only one piece of JavaScript executes at any instant.</li><li data-reveal=\"\"><strong>Parallelism</strong>: multiple tasks execute at the same instant on separate cores. Worker threads and cluster add genuine parallelism.</li></ul>\n<p>The practical consequence: <strong>concurrency solves waiting problems (I/O). Parallelism solves computing problems (CPU).</strong> Reaching for the wrong one is how slow endpoints happen.</p>\n<h2 id=\"the-event-loop-is-six-phases-not-magic\" tabindex=\"0\" data-toc-anchor=\"true\">The event loop is six phases, not \"magic\"</h2>\n<p>Every Node.js tutorial hand-waves the event loop as \"the thing that makes async work.\" That is like saying \"the engine makes the car go.\" The event loop runs in a fixed cycle of six phases. Each phase has its own queue of callbacks that it drains completely before moving to the next:</p>\n<pre is=\"pix-highlighter\"><code>┌───────────────────────────┐\n│  1. timers                │  setTimeout(), setInterval()\n├───────────────────────────┤\n│  2. pending callbacks     │  Deferred system callbacks\n├───────────────────────────┤\n│  3. idle, prepare         │  Internal libuv bookkeeping\n├───────────────────────────┤\n│  4. poll                  │  New I/O events; blocks here if idle\n├───────────────────────────┤\n│  5. check                 │  setImmediate()\n├───────────────────────────┤\n│  6. close callbacks       │  socket.on('close', ...)\n└───────────────────────────┘</code></pre><p>Phase 3 (idle, prepare) is real. Many explainers omit it because you never schedule into it directly. It exists for libuv's internal housekeeping. I mention it because omitting it is how developers grow up thinking the event loop is five phases and then get confused when something runs between poll and check.</p>\n<h3 id=\"microtasks-are-not-a-phase-and-the-order-matters\" tabindex=\"0\" data-toc-anchor=\"true\">Microtasks are not a phase - and the order matters</h3>\n<p>This is where most interview candidates fail and most production incidents begin. Microtasks are not a phase of the event loop. They drain <em>between</em> phases, and they have a strict internal order:</p>\n<pre is=\"pix-highlighter\"><code>process.nextTick()  →  drains first\nPromise.then()      →  drains second\nmacrotasks          →  timers, I/O, setImmediate - only after both microtask queues are empty</code></pre><p><code>process.nextTick</code> outranks <code>Promise.then</code>, which outranks <code>setTimeout</code>. This is not an implementation detail. It is a three-tier priority system baked into the runtime, and if you do not know it, you will write code whose execution order surprises you in production.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// Run on Node.js 24. Prove it to yourself.\nconst fs = require('node:fs');\n\nfs.readFile(__filename, () =&gt; {\n  setTimeout(() =&gt; console.log('1: setTimeout(0)'), 0);\n  setImmediate(() =&gt; console.log('2: setImmediate'));\n  Promise.resolve().then(() =&gt; console.log('3: promise'));\n  process.nextTick(() =&gt; console.log('4: nextTick'));\n});\n\n// Prints:\n// 4: nextTick\n// 3: promise\n// 2: setImmediate\n// 1: setTimeout(0)</code></pre><p><code>nextTick</code> drains first. Then the promise microtask queue. Then - because we are inside an I/O callback (poll phase) - the loop hits the check phase next, so <code>setImmediate</code> fires before the loop wraps around to the timers phase. Schedule the same <code>setTimeout(0)</code> and <code>setImmediate()</code> at the top level instead of inside an I/O callback, and the order is <em>non-deterministic</em>. Do not rely on it. This is the kind of thing that works on your machine and fails in CI.</p>\n<p>The rule: <strong><code>process.nextTick</code> → <code>Promise.then</code> → macrotasks. Always. Every time. No exceptions.</strong></p>\n<h2 id=\"the-libuv-thread-pool-what-actually-uses-it-and-what-doesn-t\" tabindex=\"0\" data-toc-anchor=\"true\">The libuv thread pool: what actually uses it (and what doesn't)</h2>\n<p>The libuv thread pool is a fixed set of background threads - <strong>4 by default, expandable to 1024</strong> - that libuv uses to run operations with no non-blocking OS primitive. The pool is shared across all event loops in a process.</p>\n<p>Here is the list of what runs on it, and this list is finite and specific:</p>\n<table>\n<thead>\n<tr>\n<th>Uses the pool</th>\n<th>Does NOT use the pool</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>fs</code> APIs (async variants)</td>\n<td>Network sockets (epoll/kqueue/IOCP)</td>\n</tr>\n<tr>\n<td><code>dns.lookup()</code> (calls <code>getaddrinfo</code>)</td>\n<td><code>dns.resolve*()</code> (uses c-ares, bypasses pool)</td>\n</tr>\n<tr>\n<td><code>crypto.pbkdf2()</code>, <code>crypto.scrypt()</code></td>\n<td>Regular TCP/HTTP requests</td>\n</tr>\n<tr>\n<td><code>crypto.randomBytes()</code>, <code>crypto.generateKeyPair()</code></td>\n<td><code>dns.resolve()</code> family</td>\n</tr>\n<tr>\n<td><code>zlib</code> compression</td>\n<td>Event loop callbacks</td>\n</tr>\n</tbody></table>\n<p>Network I/O does not touch the thread pool. It goes through the operating system's polling mechanism - epoll on Linux, kqueue on macOS, IOCP on Windows - and surfaces directly in the poll phase. The <code>dns</code> distinction trips people up constantly: <code>dns.lookup()</code> uses the pool, the <code>dns.resolve*()</code> family does not. If your mental model was \"DNS uses the pool,\" it is wrong. Half of DNS does. The other half doesn't.</p>\n<pre is=\"pix-highlighter\" data-lang=\"bash\"><code># Set BEFORE Node starts. The pool preallocates on first use.\n# Mutating process.env.UV_THREADPOOL_SIZE after the pool is touched does nothing.\nUV_THREADPOOL_SIZE=8 node server.js</code></pre><h3 id=\"the-myth-that-kills-production-servers\" tabindex=\"0\" data-toc-anchor=\"true\">The myth that kills production servers</h3>\n<p><strong>\"Bump <code>UV_THREADPOOL_SIZE</code> to speed up my API.\"</strong> No. Raising <code>UV_THREADPOOL_SIZE</code> speeds up concurrent pool-backed I/O - more parallel <code>fs</code> reads, more parallel <code>crypto</code> operations. It will never, ever speed up CPU-bound JavaScript. The pool runs libuv's C-level operations, not your functions. If your endpoint is slow because <code>fib(45)</code> runs on the main thread, <code>UV_THREADPOOL_SIZE=1024</code> does absolutely nothing. You need a worker thread.</p>\n<h2 id=\"worker-threads-they-are-not-just-os-threads\" tabindex=\"0\" data-toc-anchor=\"true\">Worker threads: they are not \"just OS threads\"</h2>\n<p>A worker thread is not an OS thread with a JavaScript face. It is a <strong>separate V8 isolate with its own event loop and its own libuv loop.</strong> This is why workers cannot share ordinary JavaScript objects. It is not a limitation. It is the architecture. Everything you <code>postMessage</code> is deep-copied via the HTML structured clone algorithm:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// main.js\nconst { Worker } = require('node:worker_threads');\n\nconst worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });\nworker.on('message', (result) =&gt; console.log('fib(42) =', result));\nworker.on('error', (err) =&gt; console.error(err));\n\n// fib-worker.js\nconst { parentPort, workerData } = require('node:worker_threads');\n\nfunction fib(n) {\n  return n &lt; 2 ? n : fib(n - 1) + fib(n - 2);\n}\n\nparentPort.postMessage(fib(workerData.n));</code></pre><p>Functions, class prototypes, and live references do not survive <code>postMessage</code>. The only escape from copying is <code>SharedArrayBuffer</code> - shared memory with all the synchronization responsibility that implies. This model trades shared-memory-by-default (and the locks, mutexes, and race conditions of C++/Java threading) for isolation-by-default. Safer, with copying as the cost. It is a deliberate design choice, not a missing feature.</p>\n<h3 id=\"do-not-spawn-a-worker-per-request\" tabindex=\"0\" data-toc-anchor=\"true\">Do not spawn a worker per request</h3>\n<p>The Node.js documentation is explicit about this, and everyone ignores it: spawning a Worker for every HTTP request is wasteful. The overhead of creating a V8 isolate, a libuv loop, and an event loop exceeds the benefit for anything but the heaviest computations. Use a pool. The community standard is <a href=\"https://www.npmjs.com/package/piscina?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>piscina</code></a>:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const Piscina = require('piscina');\nconst pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });\n\napp.get('/report', async (req, res) =&gt; {\n  const value = await pool.run({ n: 45 }); // Worker handles it; loop stays free\n  res.json({ value });\n});</code></pre><p>One pool, reused across requests. Workers stay alive. Zero startup cost per task.</p>\n<h3 id=\"the-corollary-don-t-move-already-async-work-into-a-worker\" tabindex=\"0\" data-toc-anchor=\"true\">The corollary: don't move already-async work into a worker</h3>\n<p>If you wrap <code>crypto.pbkdf2</code> in a worker thread, you have achieved nothing. That function already runs on the libuv thread pool. You have scheduled a thread (the worker) to schedule another thread (libuv). Congratulations, you added latency and memory for zero benefit. Workers earn their keep only for <strong>synchronous, CPU-bound JavaScript</strong> - heavy computation that would otherwise freeze the main thread.</p>\n<h2 id=\"the-blocking-route-that-kills-your-health-checks\" tabindex=\"0\" data-toc-anchor=\"true\">The blocking route that kills your health checks</h2>\n<p>Here is the failure mode that takes down production:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// server-blocking.js - THE WRONG WAY\nconst express = require('express');\nconst app = express();\n\nfunction fib(n) {\n  return n &lt; 2 ? n : fib(n - 1) + fib(n - 2);\n}\n\napp.get('/report', (req, res) =&gt; {\n  res.json({ value: fib(45) }); // Blocks the event loop FOR EVERYONE\n});\n\napp.get('/health', (req, res) =&gt; res.send('ok'));\n\napp.listen(3000);\n// While fib(45) runs, /health returns NOTHING. Every request queues.</code></pre><p>The production signature of a blocked event loop is distinctive: <strong>many concurrent users stall at the same wall-clock instant.</strong> Not one user's bad network. Not a slow database query for one session. Everyone freezes simultaneously because the single JavaScript thread is busy computing. You can confirm it at runtime with <code>monitorEventLoopDelay</code> from <code>node:perf_hooks</code> - a high p99 means the loop is saturated.</p>\n<p>The fix is a worker pool:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// server-pooled.js - THE RIGHT WAY\nconst Piscina = require('piscina');\nconst pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });\n\napp.get('/report', async (req, res) =&gt; {\n  const value = await pool.run({ n: 45 }); // Runs on a worker; loop stays free\n  res.json({ value });\n});\n\napp.get('/health', (req, res) =&gt; res.send('ok')); // Always responds immediately</code></pre><h2 id=\"cluster-scaling-i-o-across-cores-not-the-same-thing-as-workers\" tabindex=\"0\" data-toc-anchor=\"true\">Cluster: scaling I/O across cores (not the same thing as workers)</h2>\n<p>Cluster forks multiple <em>processes</em>, each with its own V8 isolate, its own event loop, its own memory, sharing a listening socket. Workers are multiple <em>threads</em> within one process. The distinction is not academic:</p>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>Worker Threads</th>\n<th>Cluster</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><strong>Unit</strong></td>\n<td>Thread within a process</td>\n<td>Separate OS process</td>\n</tr>\n<tr>\n<td><strong>Memory</strong></td>\n<td>Can share via <code>SharedArrayBuffer</code></td>\n<td>Fully isolated, IPC only</td>\n</tr>\n<tr>\n<td><strong>Use case</strong></td>\n<td>CPU-bound JavaScript off the main thread</td>\n<td>Scale I/O-bound throughput across cores</td>\n</tr>\n<tr>\n<td><strong>Overhead</strong></td>\n<td>V8 isolate + structured clone</td>\n<td>Full process + IPC serialization</td>\n</tr>\n</tbody></table>\n<p>In practice, a high-throughput service often uses both: cluster (or container replicas) to span cores, and a worker pool inside each process to absorb occasional CPU spikes. The mental model is not \"cluster OR workers.\" It is \"cluster for I/O scaling, workers for CPU offloading, and yes you probably want both.\"</p>\n<h2 id=\"the-decision-guide-that-replaces-stack-overflow\" tabindex=\"0\" data-toc-anchor=\"true\">The decision guide that replaces Stack Overflow</h2>\n<p>Stop searching \"Node.js concurrency best practice.\" The answer depends on your bottleneck, and there are exactly four tools:</p>\n<pre is=\"pix-highlighter\"><code>WHAT IS YOUR BOTTLENECK?\n─────────────────────────────────────────────────────────\nIs it I/O-bound (network, DB, files)?\n  └── async/await. The event loop already handles this.\n      Do not add workers. Do not add cluster.\n      The platform solved this. Use it.\n\nIs it CPU-bound JavaScript (computation, parsing, crypto)?\n  └── Worker threads, behind a pool.\n      Do not bump UV_THREADPOOL_SIZE. It won't help.\n\nIs one core saturated under concurrent I/O traffic?\n  └── Cluster across cores (or run multiple container replicas).\n      This is about throughput, not computation.\n\nAre you spawning a worker per request?\n  └── Stop. Use piscina. You are paying startup cost for nothing.\n─────────────────────────────────────────────────────────</code></pre><p>Four tools. Four bottlenecks. Four answers. Everything else is premature optimization or cargo-cult config tweaking.</p>\n<table>\n<thead>\n<tr>\n<th>Tool</th>\n<th>Runs JS in parallel?</th>\n<th>Best for</th>\n<th>Main cost</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>async/await</code> + event loop</td>\n<td>No</td>\n<td>I/O-bound work</td>\n<td>Blocks if you do CPU work on main thread</td>\n</tr>\n<tr>\n<td>libuv thread pool</td>\n<td>No (runs C, not your JS)</td>\n<td><code>fs</code>, <code>dns.lookup</code>, <code>crypto</code>, <code>zlib</code></td>\n<td>Fixed size; never accelerates your JS</td>\n</tr>\n<tr>\n<td>Worker threads + pool</td>\n<td>Yes</td>\n<td>CPU-bound JavaScript</td>\n<td>Startup + structured-clone copying</td>\n</tr>\n<tr>\n<td>Cluster / replica processes</td>\n<td>Yes</td>\n<td>Scaling I/O load across cores</td>\n<td>Process overhead; no shared state</td>\n</tr>\n</tbody></table>\n<h2 id=\"profile-before-you-parallelize\" tabindex=\"0\" data-toc-anchor=\"true\">Profile before you parallelize</h2>\n<p>The most expensive mistake in Node.js performance tuning is optimizing the wrong bottleneck. You think your endpoint is slow because of I/O, so you add cluster. It was actually a synchronous <code>JSON.parse</code> on a 5 MB payload blocking the event loop. Cluster added four more processes all blocking on the same <code>JSON.parse</code>. You made it worse.</p>\n<p>Before you touch <code>UV_THREADPOOL_SIZE</code>, before you add cluster, before you reach for workers, run <code>monitorEventLoopDelay</code>. If the p99 is high, the event loop is saturated. Then ask: is it saturated because of I/O (add cluster/replicas) or because of CPU (add workers)? The answer is in the data. The data is in <code>node:perf_hooks</code>. Use it.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const { monitorEventLoopDelay } = require('node:perf_hooks');\nconst histogram = monitorEventLoopDelay({ resolution: 20 });\nhistogram.enable();\n\n// Let it collect for a while under load, then:\nsetTimeout(() =&gt; {\n  histogram.disable();\n  console.log('Event loop delay (ms):');\n  console.log('  min:', histogram.min / 1e6);\n  console.log('  p50:', histogram.mean / 1e6);\n  console.log('  p99:', histogram.percentile(99) / 1e6);\n  console.log('  max:', histogram.max / 1e6);\n}, 30000);</code></pre><p>If p99 is above 10ms, your users feel it. Above 50ms, your health checks are at risk. Above 100ms, you are one traffic spike away from an incident. The numbers do not lie. Your intuition about where the bottleneck is probably does.</p>\n<h2 id=\"the-runtime-is-not-magic-learn-it\" tabindex=\"0\" data-toc-anchor=\"true\">The runtime is not magic. Learn it.</h2>\n<p>The OpenReplay Team's article ends with a mental model worth tattooing on your debugging workflow: <strong>stop asking \"is Node single-threaded?\" and start asking \"what is my bottleneck?\"</strong> Waiting is the event loop's job. Computing in parallel is the worker pool's. Spreading load across cores is cluster's.</p>\n<p>Profile the slow endpoint. Identify whether it is stuck waiting or stuck computing. The right tool follows directly. Everything else is guessing, and guessing in production is how 3 AM incidents happen.</p>\n<p>Node.js gives you four concurrency primitives. They are well-documented, well-tested, and stable. The reason your server is slow is not that Node is inadequate. It is that you never sat down and learned what your runtime actually does.</p>\n<p>Now you know. Go fix that endpoint.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://blog.openreplay.com/the-event-loop-worker-threads-and-concurrency-in-node-js/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">OpenReplay Team, \"The Event Loop, Worker Threads, and Concurrency in Node.js\"</a></li><li data-reveal=\"\"><a href=\"https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Node.js Event Loop Guide (official)</a></li><li data-reveal=\"\"><a href=\"https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Node.js: Don't Block the Event Loop (official)</a></li><li data-reveal=\"\"><a href=\"https://nodejs.org/api/worker_threads.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Node.js Worker Threads documentation</a></li><li data-reveal=\"\"><a href=\"http://docs.libuv.org/en/v1.x/threadpool.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">libuv thread pool documentation</a></li><li data-reveal=\"\"><a href=\"https://www.npmjs.com/package/piscina?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Piscina - worker thread pool</a></li><li data-reveal=\"\"><a href=\"https://github.com/nodejs/release?from=dout.dev#release-schedule\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Node.js release schedule</a></li></ul>\n<hr>\n<p>This article was written with AI support and reviewed by the author.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-08-10-nodejs-concurrency-event-loop.png",
      "date_published": "2026-08-10T00:00:00.000Z",
      "tags": [
        "nodejs",
        "backend",
        "performance",
        "architecture",
        "javascript"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-08-09-html-is-automation-disguised-as-markup.html",
      "url": "https://dout.dev/posts/2026-08-09-html-is-automation-disguised-as-markup.html",
      "title": "HTML Is Automation Disguised as Markup (11 Features That Deleted My JavaScript)",
      "summary": "The markup you skip is the code you rewrite",
      "content_html": "<h2 id=\"the-markup-you-skip-is-the-code-you-rewrite\" tabindex=\"0\" data-toc-anchor=\"true\">The markup you skip is the code you rewrite</h2>\n<p><a href=\"https://medium.com/@sandhya-mehtele/i-ignored-html5-for-years-until-these-11-features-saved-me-weeks-of-development-time-2a7e7c0c1b4e?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sandhya Mehtele wrote a piece</a> that should resonate with every frontend developer who has ever treated HTML as the boring part of the job. Her thesis is simple: she ignored HTML for years, reached for JavaScript and libraries to solve every problem, then discovered that the platform had already solved most of them in markup.</p>\n<p>I am going further. HTML is not just \"useful.\" <strong>HTML is a runtime.</strong> Every native element, every semantic tag, every built-in attribute is code the browser executes on your behalf - tested across billions of devices, maintained by browser vendors, and immune to framework churn. When you replace a native HTML feature with JavaScript, you are not adding functionality. You are paying to rewrite something the platform already shipped.</p>\n<p>Here are the 11 features that prove it, with the architectural reasoning Sandhya's article implies but doesn't spell out.</p>\n<h2 id=\"1-semantic-html-the-structure-is-the-documentation\" tabindex=\"0\" data-toc-anchor=\"true\">1. Semantic HTML: the structure is the documentation</h2>\n<p>The difference between a <code>&lt;div&gt;</code> and a <code>&lt;nav&gt;</code> is not cosmetic. A <code>&lt;div&gt;</code> is a rectangle. A <code>&lt;nav&gt;</code> is a <em>contract</em>: it tells the browser, the screen reader, the search engine, and the next developer that this region contains navigation.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Div soup: the browser knows nothing, the screen reader knows nothing --&gt;\n&lt;div class=\"header\"&gt;\n  &lt;div class=\"logo\"&gt;...&lt;/div&gt;\n  &lt;div class=\"nav\"&gt;\n    &lt;div class=\"nav-item\" onclick=\"navigate('/home')\"&gt;Home&lt;/div&gt;\n  &lt;/div&gt;\n&lt;/div&gt;\n&lt;div class=\"main-content\"&gt;...&lt;/div&gt;\n&lt;div class=\"footer\"&gt;...&lt;/div&gt;\n\n&lt;!-- Semantic HTML: the structure is the documentation --&gt;\n&lt;header&gt;\n  &lt;a href=\"/\" aria-label=\"Homepage\"&gt;&lt;img src=\"logo.svg\" alt=\"Acme\"&gt;&lt;/a&gt;\n  &lt;nav aria-label=\"Primary\"&gt;\n    &lt;ul&gt;\n      &lt;li&gt;&lt;a href=\"/home\"&gt;Home&lt;/a&gt;&lt;/li&gt;\n    &lt;/ul&gt;\n  &lt;/nav&gt;\n&lt;/header&gt;\n&lt;main&gt;...&lt;/main&gt;\n&lt;footer&gt;...&lt;/footer&gt;</code></pre><p>Semantic HTML eliminates the need for:</p>\n<ul>\n<li data-reveal=\"\"><code>role=\"navigation\"</code> on your nav - <code>&lt;nav&gt;</code> implies it</li><li data-reveal=\"\"><code>role=\"main\"</code> on your content wrapper - <code>&lt;main&gt;</code> implies it</li><li data-reveal=\"\"><code>role=\"banner\"</code> on your header - <code>&lt;header&gt;</code> implies it (when not nested in <code>&lt;article&gt;</code> or <code>&lt;section&gt;</code>)</li><li data-reveal=\"\"><code>role=\"contentinfo\"</code> on your footer - <code>&lt;footer&gt;</code> implies it</li><li data-reveal=\"\">JavaScript <code>onclick</code> handlers for links - <code>&lt;a href&gt;</code> handles navigation, focus, keyboard, and context menus natively</li></ul>\n<p>Every ARIA role you add to a <code>&lt;div&gt;</code> is an admission that you used the wrong element. The browser already shipped <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;header&gt;</code>, <code>&lt;footer&gt;</code>, <code>&lt;article&gt;</code>, <code>&lt;section&gt;</code>, <code>&lt;aside&gt;</code>, <code>&lt;details&gt;</code>, <code>&lt;summary&gt;</code>, <code>&lt;figure&gt;</code>, <code>&lt;figcaption&gt;</code>, <code>&lt;time&gt;</code>, <code>&lt;address&gt;</code>, and more. Each one carries implicit ARIA semantics. Each one is understood by every screen reader released in the last decade. Each one costs zero JavaScript.</p>\n<p>The rule: <strong>if your UI only works because of CSS hacks and ARIA duct tape, your HTML structure is broken.</strong> Fix the structure first. The styles will follow.</p>\n<h2 id=\"2-native-form-validation-kill-200-lines-of-javascript\" tabindex=\"0\" data-toc-anchor=\"true\">2. Native form validation: kill 200 lines of JavaScript</h2>\n<p>Every frontend developer has written some version of this:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// The JavaScript you no longer need\nfunction validateEmail(email) {\n  const re = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n  return re.test(email);\n}\n\nfunction validateForm() {\n  const email = document.getElementById('email').value;\n  const password = document.getElementById('password').value;\n  const errors = [];\n\n  if (!email) errors.push('Email is required');\n  else if (!validateEmail(email)) errors.push('Invalid email');\n\n  if (!password) errors.push('Password is required');\n  else if (password.length &lt; 8) errors.push('Password must be at least 8 characters');\n\n  if (errors.length) {\n    errors.forEach(e =&gt; showError(e));\n    return false;\n  }\n  return true;\n}</code></pre><p>Here is the HTML that replaces all of it:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;form&gt;\n  &lt;label for=\"email\"&gt;Email&lt;/label&gt;\n  &lt;input\n    type=\"email\"\n    id=\"email\"\n    name=\"email\"\n    required\n    autocomplete=\"email\"\n  &gt;\n\n  &lt;label for=\"password\"&gt;Password&lt;/label&gt;\n  &lt;input\n    type=\"password\"\n    id=\"password\"\n    name=\"password\"\n    required\n    minlength=\"8\"\n    autocomplete=\"current-password\"\n  &gt;\n\n  &lt;button type=\"submit\"&gt;Sign in&lt;/button&gt;\n&lt;/form&gt;</code></pre><p>What the browser gives you for free:</p>\n<ul>\n<li data-reveal=\"\"><strong><code>type=\"email\"</code></strong>: validates email format, triggers the <code>@</code> key on mobile keyboards</li><li data-reveal=\"\"><strong><code>required</code></strong>: blocks submission, shows a localized error message</li><li data-reveal=\"\"><strong><code>minlength=\"8\"</code></strong>: blocks submission if the password is too short</li><li data-reveal=\"\"><strong><code>autocomplete</code></strong>: lets the browser prefill saved credentials, no JavaScript</li><li data-reveal=\"\"><strong><code>:invalid</code> and <code>:valid</code> pseudo-classes</strong>: style validation states with zero JavaScript</li></ul>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>input:invalid {\n  border-color: var(--color-error);\n}\n\ninput:valid {\n  border-color: var(--color-success);\n}</code></pre><p>This is not \"nice to have.\" This is <strong>200 lines of JavaScript deleted and replaced by four HTML attributes.</strong> The validation is more robust than your hand-rolled regex (the browser's email validator handles internationalized domain names, quoted local parts, and edge cases you forgot). It works offline. It is accessible by default. It ships in every browser.</p>\n<p>The only legitimate reason to write custom form validation in 2026 is when the constraint is genuinely custom - two fields must match, or a value depends on an API response. Even then, use <code>setCustomValidity()</code> on the native constraint validation API instead of building your own error system. Work with the platform. Don't pave over it.</p>\n<h2 id=\"3-the-pattern-attribute-regex-validation-without-javascript\" tabindex=\"0\" data-toc-anchor=\"true\">3. The <code>pattern</code> attribute: regex validation without JavaScript</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Username: 3-20 alphanumeric characters, must start with a letter --&gt;\n&lt;input\n  type=\"text\"\n  pattern=\"[A-Za-z][A-Za-z0-9]{2,19}\"\n  title=\"Username must be 3-20 characters, starting with a letter\"\n  required\n&gt;\n\n&lt;!-- Italian VAT number: 11 digits --&gt;\n&lt;input\n  type=\"text\"\n  pattern=\"[0-9]{11}\"\n  title=\"Enter an 11-digit VAT number\"\n  inputmode=\"numeric\"\n&gt;\n\n&lt;!-- Hex color --&gt;\n&lt;input\n  type=\"text\"\n  pattern=\"#[0-9A-Fa-f]{6}\"\n  title=\"Enter a hex color like #FF6600\"\n&gt;</code></pre><p>The <code>pattern</code> attribute runs a regex against the input value on form submission. If it fails, the form does not submit and the <code>title</code> text appears as the error message. No JavaScript. No library. No custom validation function. Just a regex and a human-readable description.</p>\n<p>This also reduces bad data at the backend. If the pattern blocks <code>\"not-a-color\"</code> before it reaches your server, you have fewer validation edge cases to handle, fewer error responses to render, and a smaller attack surface. The frontend validation is not a replacement for backend validation - it is a free pre-filter that catches honest mistakes before they cost a round-trip.</p>\n<h2 id=\"4-the-download-attribute-file-downloads-without-a-backend\" tabindex=\"0\" data-toc-anchor=\"true\">4. The <code>download</code> attribute: file downloads without a backend</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- A PDF that downloads instead of opening in the browser --&gt;\n&lt;a href=\"/reports/q4-2026.pdf\" download&gt;\n  Download Q4 Report (PDF)\n&lt;/a&gt;\n\n&lt;!-- Download with a custom filename --&gt;\n&lt;a href=\"/generated/invoice-83721.pdf\" download=\"Acme-Invoice-83721.pdf\"&gt;\n  Download Invoice\n&lt;/a&gt;\n\n&lt;!-- A CSV export generated client-side from a blob --&gt;\n&lt;a id=\"export-link\" download=\"data.csv\"&gt;Export Data&lt;/a&gt;\n&lt;script&gt;\n  const csv = 'Name,Email\\nAlice,alice@example.com\\n';\n  const blob = new Blob([csv], { type: 'text/csv' });\n  document.getElementById('export-link').href = URL.createObjectURL(blob);\n&lt;/script&gt;</code></pre><p>Before the <code>download</code> attribute, triggering a file download required a <code>Content-Disposition</code> header on the server, or a server-side proxy, or a form POST with the right encoding. Now a single attribute on an <code>&lt;a&gt;</code> tag solves it. Combine it with <code>URL.createObjectURL()</code> for client-side generated files, and you have a fully client-side export system with zero backend involvement.</p>\n<h2 id=\"5-contenteditable-rich-text-editing-without-a-library\" tabindex=\"0\" data-toc-anchor=\"true\">5. <code>contenteditable</code>: rich text editing without a library</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;div contenteditable=\"true\" role=\"textbox\" aria-multiline=\"true\"&gt;\n  Start typing here. &lt;strong&gt;Bold&lt;/strong&gt;, &lt;em&gt;italic&lt;/em&gt;, whatever you want.\n&lt;/div&gt;</code></pre><p>For internal tools - admin panels, CMS backends, dashboards - <code>contenteditable</code> replaces entire rich text libraries. The browser handles cursor position, text selection, undo/redo, spell check, and keyboard navigation. You get <code>document.execCommand()</code> for bold, italic, lists, links. Or better, use the modern <code>Input Events</code> API for structured editing.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// Listen for changes without polling\nconst editor = document.querySelector('[contenteditable]');\neditor.addEventListener('input', (e) =&gt; {\n  console.log('Content changed:', e.target.innerHTML);\n});</code></pre><p>Is this a replacement for a full collaborative editor like ProseMirror or Slate? No. Those libraries exist for a reason - real-time collaboration, structured document models, version history. But for an admin panel where someone needs to edit a product description, <code>contenteditable</code> with ten lines of JavaScript handles the entire use case. No 200 KB editor bundle. No dependency tree. No framework-specific wrapper.</p>\n<h2 id=\"6-autofocus-one-attribute-one-click-deleted\" tabindex=\"0\" data-toc-anchor=\"true\">6. <code>autofocus</code>: one attribute, one click deleted</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- The search field is ready before the user's finger touches the keyboard --&gt;\n&lt;input type=\"search\" name=\"q\" autofocus placeholder=\"Search articles...\"&gt;\n\n&lt;!-- Login form: focus on the email field --&gt;\n&lt;input type=\"email\" name=\"email\" autofocus autocomplete=\"email\"&gt;</code></pre><p>Every login page, every search bar, every form where the first action is obvious - the user should not have to click the first field. <code>autofocus</code> places the cursor automatically. One attribute. Zero JavaScript. No <code>document.getElementById('email').focus()</code>. No <code>useEffect(() =&gt; ref.current?.focus(), [])</code>. No timing bugs where the focus fires before the DOM is ready.</p>\n<p>The browser handles edge cases: <code>autofocus</code> respects <code>prefers-reduced-motion</code>, it does not steal focus from a different tab, and it only fires once per page load. Your JavaScript implementation probably missed at least one of these.</p>\n<h2 id=\"7-the-hidden-attribute-state-management-without-css\" tabindex=\"0\" data-toc-anchor=\"true\">7. The <code>hidden</code> attribute: state management without CSS</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Hidden until JavaScript toggles it --&gt;\n&lt;div id=\"error-message\" hidden&gt;\n  Something went wrong. Please try again.\n&lt;/div&gt;\n\n&lt;script&gt;\n  // Show the error\n  document.getElementById('error-message').hidden = false;\n\n  // Hide it again\n  document.getElementById('error-message').hidden = true;\n&lt;/script&gt;</code></pre><p>Before <code>hidden</code>, toggling visibility required:</p>\n<ul>\n<li data-reveal=\"\"><code>element.style.display = 'none'</code> - overwrites any existing display value, breaks flex/grid items when restored</li><li data-reveal=\"\"><code>element.classList.toggle('hidden')</code> - requires a CSS rule <code>.hidden { display: none !important }</code>, adds a specificity landmine</li><li data-reveal=\"\">A framework-specific conditional render - <code>{error &amp;&amp; &lt;ErrorMessage /&gt;}</code>, which unmounts the element entirely and resets its state</li></ul>\n<p>The <code>hidden</code> attribute is none of these. It is a native HTML boolean attribute with a single, predictable behavior: the element is removed from the accessibility tree and hidden from view. You toggle <code>element.hidden = true/false</code>. No CSS conflict. No specificity war. No lost state. The platform does exactly what you mean.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Style hidden elements differently when they become visible */\n#error-message:not([hidden]) {\n  display: flex;\n  gap: 0.5rem;\n  padding: 1rem;\n  background: var(--color-error-bg);\n  border-radius: var(--radius-md);\n}</code></pre><h2 id=\"8-native-progress-bars-visual-feedback-without-a-library\" tabindex=\"0\" data-toc-anchor=\"true\">8. Native progress bars: visual feedback without a library</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Determinate: value is known --&gt;\n&lt;label for=\"upload-progress\"&gt;Uploading files...&lt;/label&gt;\n&lt;progress id=\"upload-progress\" value=\"65\" max=\"100\"&gt;65%&lt;/progress&gt;\n\n&lt;!-- Indeterminate: value is unknown, bar animates automatically --&gt;\n&lt;progress id=\"loading\"&gt;Loading...&lt;/progress&gt;\n\n&lt;script&gt;\n  const bar = document.getElementById('upload-progress');\n  let percent = 0;\n  const interval = setInterval(() =&gt; {\n    percent += 5;\n    bar.value = percent;\n    if (percent &gt;= 100) clearInterval(interval);\n  }, 200);\n&lt;/script&gt;</code></pre><p>The <code>&lt;progress&gt;</code> element renders a native progress bar. Determinate mode (<code>value</code> + <code>max</code>) shows a filled bar. Indeterminate mode (no <code>value</code>) shows an animated striped bar - the browser handles the animation, no CSS <code>@keyframes</code> required.</p>\n<p>Use cases: file uploads, background job status, multi-step form progress, data sync indicators. Every one of these was previously implemented with a <code>&lt;div&gt;</code> and a width percentage in JavaScript, or worse, an entire progress bar library. The platform ships one. Use it.</p>\n<h2 id=\"9-meter-visual-indicators-that-feel-professional\" tabindex=\"0\" data-toc-anchor=\"true\">9. <code>&lt;meter&gt;</code>: visual indicators that feel professional</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Disk usage: 68% --&gt;\n&lt;label for=\"disk-usage\"&gt;Disk Usage&lt;/label&gt;\n&lt;meter id=\"disk-usage\" value=\"0.68\" min=\"0\" max=\"1\"&gt;68%&lt;/meter&gt;\n\n&lt;!-- Risk score with thresholds --&gt;\n&lt;meter value=\"75\" min=\"0\" max=\"100\" low=\"33\" high=\"66\" optimum=\"0\"&gt;\n  Risk: 75/100\n&lt;/meter&gt;\n\n&lt;!-- CPU usage: gauge-style --&gt;\n&lt;label for=\"cpu\"&gt;CPU&lt;/label&gt;\n&lt;meter id=\"cpu\" value=\"0.42\" min=\"0\" max=\"1\" low=\"0.5\" high=\"0.8\" optimum=\"0.2\"&gt;42%&lt;/meter&gt;</code></pre><p><code>&lt;meter&gt;</code> is the semantic sibling of <code>&lt;progress&gt;</code>, designed for scalar measurements within a known range - disk usage, risk scores, quotas, ratings. It has built-in threshold styling: values below <code>low</code> get one color, between <code>low</code> and <code>high</code> another, above <code>high</code> a third. The browser handles the color transitions. You get a professional-grade gauge with one HTML element.</p>\n<p>This is the kind of element developers rebuild in JavaScript because they do not know it exists. Stop rebuilding platform features. Learn them instead.</p>\n<h2 id=\"10-email-and-url-inputs-stop-writing-sanitizers\" tabindex=\"0\" data-toc-anchor=\"true\">10. Email and URL inputs: stop writing sanitizers</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Email: validates format, triggers email keyboard on mobile --&gt;\n&lt;input type=\"email\" name=\"email\" autocomplete=\"email\" required&gt;\n\n&lt;!-- URL: validates protocol, triggers URL keyboard on mobile --&gt;\n&lt;input type=\"url\" name=\"website\" autocomplete=\"url\" placeholder=\"https://example.com\"&gt;\n\n&lt;!-- Tel: triggers phone keypad, no format validation --&gt;\n&lt;input type=\"tel\" name=\"phone\" autocomplete=\"tel\" inputmode=\"tel\"&gt;\n\n&lt;!-- Number: numeric keyboard, built-in min/max/step --&gt;\n&lt;input type=\"number\" name=\"quantity\" min=\"1\" max=\"99\" step=\"1\" value=\"1\"&gt;\n\n&lt;!-- Date: native date picker, localized format --&gt;\n&lt;input type=\"date\" name=\"birthday\" min=\"1920-01-01\" max=\"2026-12-31\"&gt;\n\n&lt;!-- Search: clear button, search keyboard on mobile --&gt;\n&lt;input type=\"search\" name=\"q\" autocomplete=\"search\" aria-label=\"Search articles\"&gt;</code></pre><p>Every one of these input types:</p>\n<ul>\n<li data-reveal=\"\"><strong>Validates input</strong> before the form submits (<code>type=\"email\"</code> rejects <code>\"not-an-email\"</code>)</li><li data-reveal=\"\"><strong>Shows the correct mobile keyboard</strong> (<code>type=\"number\"</code> shows numeric keypad, <code>type=\"tel\"</code> shows phone keypad)</li><li data-reveal=\"\"><strong>Provides a native UI</strong> (<code>type=\"date\"</code> shows a date picker, <code>type=\"search\"</code> shows a clear button)</li><li data-reveal=\"\"><strong>Works offline</strong> (validation runs in the browser, no round-trip)</li><li data-reveal=\"\"><strong>Is accessible by default</strong> (screen readers announce the input type)</li></ul>\n<p>If you are using <code>&lt;input type=\"text\"&gt;</code> with a custom JavaScript validator for email, URL, number, date, or search, you are rewriting platform code. Stop it. The browser already ships the validator, the keyboard, the picker, and the accessibility tree. Your job is to use them, not to replace them.</p>\n<h2 id=\"11-mark-and-time-semantic-power-moves\" tabindex=\"0\" data-toc-anchor=\"true\">11. <code>&lt;mark&gt;</code> and <code>&lt;time&gt;</code>: semantic power moves</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Highlight search matches --&gt;\n&lt;p&gt;\n  Results for \"&lt;mark&gt;CSS Grid&lt;/mark&gt;\": found in 3 articles.\n  The &lt;mark&gt;CSS Grid&lt;/mark&gt; specification defines a two-dimensional layout system...\n&lt;/p&gt;\n\n&lt;!-- Machine-readable time with human-readable display --&gt;\n&lt;p&gt;\n  Published on\n  &lt;time datetime=\"2026-08-09T10:00:00+02:00\"&gt;August 9, 2026&lt;/time&gt;\n&lt;/p&gt;\n\n&lt;!-- Duration --&gt;\n&lt;p&gt;\n  Estimated reading time:\n  &lt;time datetime=\"PT7M\"&gt;7 minutes&lt;/time&gt;\n&lt;/p&gt;</code></pre><p><code>&lt;mark&gt;</code> semantically indicates highlighted text - typically for search results or passages of interest. Screen readers may announce it differently. Search engines can extract it as relevant context. It communicates <em>intent</em> in a way <code>&lt;span class=\"highlight\"&gt;</code> does not.</p>\n<p><code>&lt;time&gt;</code> makes dates and durations machine-readable via the <code>datetime</code> attribute while displaying human-friendly text. Browsers can offer \"Add to Calendar\" for events. Search engines extract structured data without JSON-LD. Assistive technology can convert relative times (\"3 days ago\") to absolute dates.</p>\n<p>These are not flashy features. They are quiet, structural improvements that compound across a project: better SEO, better accessibility, better interoperability. The kind of thing JavaScript cannot fix retroactively because by the time the script runs, the parser has already decided what the document means.</p>\n<h2 id=\"the-meta-lesson-the-platform-is-the-framework\" tabindex=\"0\" data-toc-anchor=\"true\">The meta-lesson: the platform is the framework</h2>\n<p>Sandhya closes with a line worth engraving: <strong>\"HTML5 is automation disguised as markup.\"</strong> She is right, and the implication is bigger than she states.</p>\n<p>Every native HTML feature you use is code you do not write, test, debug, bundle, update, document, or maintain. The browser vendor does all of that for you, for free, across every device their engine runs on. When you replace a native feature with a JavaScript implementation, you are not adding capability. You are moving code from the platform (where it is free, fast, and maintained by someone else) to your bundle (where it costs you everything).</p>\n<p>The 11 features above are the tip of the iceberg. There are more: <code>&lt;datalist&gt;</code> for autocomplete, <code>&lt;details&gt;/&lt;summary&gt;</code> for collapsible sections, <code>&lt;dialog&gt;</code> for modals, <code>&lt;output&gt;</code> for calculation results, <code>&lt;template&gt;</code> for reusable fragments, <code>&lt;slot&gt;</code> for content projection, <code>&lt;picture&gt;</code> for responsive images, <code>&lt;map&gt;</code> for image maps, <code>&lt;abbr&gt;</code> for abbreviations, <code>&lt;dfn&gt;</code> for definitions, <code>&lt;kbd&gt;</code> for keyboard input, <code>&lt;samp&gt;</code> for sample output, <code>&lt;del&gt;</code> and <code>&lt;ins&gt;</code> for document changes.</p>\n<p>Every one replaces JavaScript you were about to write.</p>\n<p>The developers who ship fastest are not the ones who know the most frameworks. They are the ones who know what the platform already does, and only write code for what it does not.</p>\n<p>Stop treating HTML as a wrapper. It is a runtime. Learn it like one.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://medium.com/@sandhya-mehtele/i-ignored-html5-for-years-until-these-11-features-saved-me-weeks-of-development-time-2a7e7c0c1b4e?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sandhya Mehtele, \"I Ignored HTML5 for Years, Until These 11 Features Saved Me Weeks of Development Time\"</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: HTML elements reference</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: Client-side form validation</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: Constraint validation API</a></li><li data-reveal=\"\"><a href=\"https://html.spec.whatwg.org/multipage/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">HTML Living Standard</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/TR/html-aria/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">ARIA in HTML (which elements have implicit roles)</a></li></ul>\n<hr>\n<p>This article was written with AI support and reviewed by the author.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-08-09-html-is-automation-disguised-as-markup.png",
      "date_published": "2026-08-09T00:00:00.000Z",
      "tags": [
        "html",
        "frontend",
        "web-standards",
        "a11y",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-08-06-css-grid-vs-flexbox-2026.html",
      "url": "https://dout.dev/posts/2026-08-06-css-grid-vs-flexbox-2026.html",
      "title": "CSS Grid vs Flexbox in 2026 - Stop Guessing, Start Knowing",
      "summary": "The war that never should have happened",
      "content_html": "<h2 id=\"the-war-that-never-should-have-happened\" tabindex=\"0\" data-toc-anchor=\"true\">The war that never should have happened</h2>\n<p>For nearly a decade, frontend engineers have treated CSS Grid and Flexbox as opposing factions. \"Should I build this layout with Grid <em>or</em> Flexbox?\" The question itself is broken. It is like asking whether you should build a house with a hammer <em>or</em> a saw. You need both. The difference between a senior and a junior CSS architect is knowing which tool activates before typing a single property.</p>\n<p><a href=\"https://medium.com/@rahulkaklotar/css-grid-vs-flexbox-in-2026-stop-guessing-start-knowing-38ca7f0f9dad?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Rahul Kaklotar wrote the definitive technical breakdown</a> of how these two engines work under the hood and when to reach for each. What follows builds on his blueprint and adds the layer most tutorials skip: <strong>the architectural reasoning that makes the choice obvious before you write any code.</strong></p>\n<h2 id=\"the-mental-model-that-changes-everything\" tabindex=\"0\" data-toc-anchor=\"true\">The mental model that changes everything</h2>\n<p>CSS layout engines are not competing. They solve fundamentally different spatial problems.</p>\n<pre is=\"pix-highlighter\"><code>┌────────────────────────────────────────────────────────┐\n│                Modern Layout Engine                    │\n└──────────────────────────┬─────────────────────────────┘\n                           │\n      ┌────────────────────┴────────────────────┐\n      ▼                                         ▼\n┌─────────────────────────┐       ┌─────────────────────────┐\n│     Flexbox (1D)        │       │     CSS Grid (2D)       │\n├─────────────────────────┤       ├─────────────────────────┤\n│ • Content-driven        │       │ • Structure-driven      │\n│ • Single-axis flow      │       │ • Dual-axis alignment   │\n│ • Dynamic space sharing │       │ • Rigorous track grid   │\n│ • \"Content decides\"     │       │ • \"Container decides\"   │\n└─────────────────────────┘       └─────────────────────────┘</code></pre><p><strong>Flexbox is content-first.</strong> You put items in a row. The items tell the container how much space they need. The container distributes the remainder. When it wraps, each row is an independent universe - items in row 2 have zero awareness of column edges in row 1.</p>\n<p><strong>CSS Grid is structure-first.</strong> You define the tracks. The tracks exist <em>before</em> the content arrives. Children are placed into coordinate intersections. An item in Row 2, Column 3 is bound to the exact track boundaries as the item in Row 1, Column 3. The grid enforces alignment. You cannot opt out.</p>\n<p>This is not a philosophical difference. It is a mechanical one. And it has concrete consequences for every layout decision you make.</p>\n<h2 id=\"flexbox-the-sizing-algorithm-you-need-to-understand\" tabindex=\"0\" data-toc-anchor=\"true\">Flexbox: the sizing algorithm you need to understand</h2>\n<p>The number one Flexbox mistake is treating it as \"a row of things\" without understanding how the browser calculates widths. Here is the formula Rahul surfaced:</p>\n<pre is=\"pix-highlighter\"><code>FLEX ITEM SIZING FORMULA\n────────────────────────────────────────────\nAvailable Free Space = Container Width − SUM(flex-basis)\n\nIf Free Space &gt; 0:\n  Final Width = flex-basis + (flex-grow / SUM(flex-grow)) × Free Space\n\nIf Free Space &lt; 0:\n  Final Width = flex-basis − (flex-shrink-scaled / SUM(flex-shrink-scaled)) × |Free Space|\n────────────────────────────────────────────</code></pre><p>When you write <code>flex: 1 1 200px</code>, you are configuring three engine parameters: how much the item <em>wants</em> (<code>flex-basis</code>), how much it <em>takes</em> from leftover space (<code>flex-grow</code>), and how much it <em>gives up</em> under pressure (<code>flex-shrink</code>).</p>\n<h3 id=\"the-flex-basis-0-vs-flex-basis-auto-distinction-everyone-gets-wrong\" tabindex=\"0\" data-toc-anchor=\"true\">The <code>flex-basis: 0</code> vs <code>flex-basis: auto</code> distinction everyone gets wrong</h3>\n<p>This is the single most misunderstood mechanic in Flexbox:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Equal widths regardless of content length */\n.equal-columns {\n  flex: 1 1 0%;\n  /* Basis is zero. ALL container width is free space.\n     Every item gets exactly one share. Content doesn't matter. */\n}\n\n/* Proportional sizing based on content + remaining space */\n.natural-columns {\n  flex: 1 1 auto;\n  /* Basis is the item's intrinsic content width.\n     Only leftover space is distributed by flex-grow.\n     A longer word gets more base width. */\n}</code></pre><p>Use <code>flex-basis: 0</code> when you want <em>enforced visual equality</em>. Use <code>flex-basis: auto</code> when you want <em>natural reading flow that expands proportionally</em>. Confuse the two and you will spend an hour wondering why your equal-width columns are not equal.</p>\n<h3 id=\"the-single-line-align-content-upgrade\" tabindex=\"0\" data-toc-anchor=\"true\">The single-line <code>align-content</code> upgrade</h3>\n<p>Modern browser engines now support <code>align-content</code> on single-line flex containers - a feature that historically required <code>align-items</code> hacks or <code>margin: auto</code> tricks:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.card-actions {\n  display: flex;\n  flex-direction: row;\n  align-content: center; /* Works on single-line containers in 2026 */\n  gap: 0.75rem;\n}</code></pre><p>This closes one of the last ergonomic gaps between Flexbox and Grid for vertical centering.</p>\n<h3 id=\"production-blueprint-the-universal-navigation-header\" tabindex=\"0\" data-toc-anchor=\"true\">Production blueprint: the universal navigation header</h3>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.site-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 1.5rem;\n  padding: 1rem 2rem;\n}\n\n.nav-group {\n  display: flex;\n  align-items: center;\n  gap: 1rem;\n}\n\n/* Push user profile to the far right without wrapper divs */\n.user-profile {\n  margin-inline-start: auto;\n}</code></pre><p>This is Flexbox at its best: a single-axis flow where one item (<code>margin-inline-start: auto</code>) consumes all remaining space and pushes itself to the edge. No grid declaration. No column math. The browser calculates the space distribution in O(N) time.</p>\n<h2 id=\"css-grid-when-the-container-must-define-the-structure\" tabindex=\"0\" data-toc-anchor=\"true\">CSS Grid: when the container must define the structure</h2>\n<p>Grid activates when the layout is a <em>system</em>, not a <em>list</em>. The key engineering difference is that Grid solves a constraint problem across two axes simultaneously.</p>\n<h3 id=\"tracks-fractional-units-and-sizing-functions\" tabindex=\"0\" data-toc-anchor=\"true\">Tracks, fractional units, and sizing functions</h3>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.grid-container {\n  display: grid;\n  grid-template-columns: 250px 1fr 2fr;\n  gap: 1.5rem;\n}</code></pre><p>After the fixed 250px track is allocated, the remaining space is split into 3 fractions. Track 2 gets 1. Track 3 gets 2. The container defines the structure. The content adapts.</p>\n<p>The sizing primitives give you surgical control:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.grid-advanced {\n  grid-template-columns:\n    minmax(200px, 400px)  /* Clamped: never smaller than 200, never larger than 400 */\n    fit-content(500px)    /* Intrinsic size, capped at 500px */\n    max-content           /* Expand to fit the longest unbroken string */\n    min-content;          /* Contract to the widest single word */\n}</code></pre><h3 id=\"auto-fill-vs-auto-fit-the-responsive-grid-you-don-t-need-media-queries-for\" tabindex=\"0\" data-toc-anchor=\"true\"><code>auto-fill</code> vs <code>auto-fit</code>: the responsive grid you don't need media queries for</h3>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* AUTO-FILL: Creates empty tracks if space permits */\n.grid-auto-fill {\n  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n}\n\n/* AUTO-FIT: Collapses empty tracks, items stretch to fill */\n.grid-auto-fit {\n  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));\n}</code></pre><p>At 1000px container width with 280px minimum items:</p>\n<pre is=\"pix-highlighter\"><code>AUTO-FILL (2 items present):\n┌──────────┬──────────┬────────────┐\n│  Item 1  │  Item 2  │ (empty)    │  ← Track created, stays empty\n└──────────┴──────────┴────────────┘\n\nAUTO-FIT (2 items present):\n┌────────────────────┬────────────────────┐\n│  Item 1 (stretched)│  Item 2 (stretched)│  ← Empty track collapsed\n└────────────────────┴────────────────────┘</code></pre><p><strong>Use <code>auto-fill</code> when you want a fixed grid that leaves gaps for potential future items.</strong> Use <code>auto-fit</code> when you want the existing items to expand and fill the container. Choose wrong and you have either awkward empty columns or unexpectedly wide cards. Know the difference and your responsive grids become deterministic.</p>\n<h3 id=\"grid-template-areas-the-visual-layout-map\" tabindex=\"0\" data-toc-anchor=\"true\"><code>grid-template-areas</code>: the visual layout map</h3>\n<p>This is the feature that makes Grid a genuine engineering tool:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.app-shell {\n  display: grid;\n  grid-template-areas:\n    \"header  header\"\n    \"sidebar main\"\n    \"footer  footer\";\n  grid-template-columns: 260px 1fr;\n  grid-template-rows: auto 1fr auto;\n  min-height: 100vh;\n}\n\n.header  { grid-area: header; }\n.sidebar { grid-area: sidebar; }\n.main    { grid-area: main; }\n.footer  { grid-area: footer; }</code></pre><p>The CSS <em>is</em> the wireframe. A new team member reads the <code>grid-template-areas</code> block and understands the entire page structure in five seconds. No framework, no component tree traversal, no layout documentation that has drifted from the code. The layout is the documentation.</p>\n<h3 id=\"subgrid-the-feature-that-kills-nested-grid-hacks\" tabindex=\"0\" data-toc-anchor=\"true\">Subgrid: the feature that kills nested grid hacks</h3>\n<p>Subgrid lets nested elements inherit and lock onto the parent's track lines:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.parent-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 1.5rem;\n}\n\n/* Spans all 3 parent columns, but subgrid aligns internal content */\n.card-composite {\n  grid-column: 1 / -1;\n  display: grid;\n  grid-template-columns: subgrid; /* Inherits parent tracks */\n}\n\n.card-composite .header { grid-column: 1 / 3; }\n.card-composite .aside  { grid-column: 3 / 4; }</code></pre><p>Before subgrid, aligning nested card headers across different cards required JavaScript to measure heights and set <code>min-height</code> values. Now the browser does it natively. This is the kind of platform absorption that makes utility libraries obsolete - a problem that used to require a framework or a script is now a one-line CSS declaration.</p>\n<h2 id=\"container-queries-the-missing-piece-of-the-layout-puzzle\" tabindex=\"0\" data-toc-anchor=\"true\">Container queries: the missing piece of the layout puzzle</h2>\n<p>Grid and Flexbox decide <em>how</em> things flow. Container queries decide <em>when</em> the flow changes. Together they make responsive design component-driven instead of viewport-driven:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.card-wrapper {\n  container-type: inline-size;\n  container-name: card;\n}\n\n/* Default: stack vertically (Flexbox) */\n.card-component {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n/* Wider container: switch to 2D layout (Grid) */\n@container card (min-width: 550px) {\n  .card-component {\n    display: grid;\n    grid-template-columns: 200px 1fr;\n    grid-template-rows: auto 1fr;\n  }\n  .card-media {\n    grid-row: 1 / -1;\n  }\n}</code></pre><p>The component queries its <em>own parent</em>, not the viewport. Place it in a narrow sidebar - it stacks. Place it in a full-width article - it goes side-by-side. Same component, same code, zero media queries. This is the correct abstraction for reusable UI: the component adapts to the space it is given, not the space of the entire browser window.</p>\n<h2 id=\"the-performance-reality-check\" tabindex=\"0\" data-toc-anchor=\"true\">The performance reality check</h2>\n<p>Rahul's analysis of layout pass complexity is worth internalizing:</p>\n<table>\n<thead>\n<tr>\n<th>Engine</th>\n<th>Complexity</th>\n<th>What it means</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><strong>Flexbox</strong></td>\n<td>O(N)</td>\n<td>Linear pass along one axis. Fast. Fixed <code>flex-basis: 0</code> makes child calculations O(1).</td>\n</tr>\n<tr>\n<td><strong>Grid</strong></td>\n<td>O(N log N)</td>\n<td>Constraint solver across two axes. Explicit templates (<code>grid-template-areas</code>) bypass auto-placement and reduce cost.</td>\n</tr>\n</tbody></table>\n<p>This is not an argument against Grid. It is an argument against <em>using Grid for single-axis layouts</em>. When you write <code>display: grid; grid-template-columns: repeat(4, auto)</code> for a button row, you are paying O(N log N) for a problem Flexbox solves in O(N). The performance difference is invisible for a single component, but it compounds across a page with hundreds of layout containers.</p>\n<p>The rule: <strong>Flexbox for lists. Grid for systems. Container queries for behavior.</strong></p>\n<h2 id=\"the-anti-patterns-that-betray-inexperience\" tabindex=\"0\" data-toc-anchor=\"true\">The anti-patterns that betray inexperience</h2>\n<h3 id=\"anti-pattern-1-forcing-flexbox-to-simulate-grid\" tabindex=\"0\" data-toc-anchor=\"true\">Anti-pattern 1: Forcing Flexbox to simulate Grid</h3>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* WRONG: calc() hacks to fake 2D alignment */\n.bad-fake-grid {\n  display: flex;\n  flex-wrap: wrap;\n  margin: -10px;\n}\n.bad-fake-grid &gt; * {\n  width: calc(33.333% - 20px);\n  margin: 10px;\n}\n\n/* CORRECT: the browser already has this engine */\n.good-real-grid {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 20px;\n}</code></pre><p>If you are doing math in <code>calc()</code> to make flex items align in columns, you are using the wrong tool. CSS Grid was literally built for this.</p>\n<h3 id=\"anti-pattern-2-grid-for-simple-button-rows\" tabindex=\"0\" data-toc-anchor=\"true\">Anti-pattern 2: Grid for simple button rows</h3>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* WRONG: a 2D constraint solver for a 1D problem */\n.bad-button-group {\n  display: grid;\n  grid-template-columns: repeat(4, auto);\n  gap: 1rem;\n}\n\n/* CORRECT: the 1D engine for a 1D problem */\n.good-button-group {\n  display: flex;\n  gap: 1rem;\n}</code></pre><p>Every unnecessary Grid declaration is O(N log N) work the browser does not need to do.</p>\n<h3 id=\"anti-pattern-3-ignoring-min-width-0-in-flex-containers\" tabindex=\"0\" data-toc-anchor=\"true\">Anti-pattern 3: Ignoring <code>min-width: 0</code> in flex containers</h3>\n<p>Flex items default to <code>min-width: auto</code>, which means they refuse to shrink below their intrinsic content size:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Without this, long URLs or unbroken strings overflow the container */\n.flex-item-with-truncation {\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}</code></pre><p>This is the single most common Flexbox bug in production and the one that wastes the most debugging time because the visual result (overflow) looks like a Grid problem when it is actually a Flexbox default.</p>\n<h2 id=\"the-decision-flowchart-that-replaces-trial-and-error\" tabindex=\"0\" data-toc-anchor=\"true\">The decision flowchart that replaces trial-and-error</h2>\n<pre is=\"pix-highlighter\"><code>LAYOUT SELECTION - ASK IN ORDER\n─────────────────────────────────────────────\nQ1: Do you need strict 2D column/row alignment?\n    ├── YES → CSS GRID. Stop asking.\n    └── NO  → Proceed to Q2.\n\nQ2: Is the layout driven by content length or dynamic wrapping?\n    ├── YES → FLEXBOX. Stop asking.\n    └── NO  → Proceed to Q3.\n\nQ3: Are you positioning major page-level structural zones?\n    ├── YES → CSS GRID (grid-template-areas).\n    └── NO  → FLEXBOX. It's a list. Treat it like one.\n─────────────────────────────────────────────</code></pre><p>Three questions. No guesswork. No \"I'll try Grid and see if it works.\" The choice falls out of the problem description.</p>\n<h2 id=\"the-pre-pr-checklist-for-layout-code\" tabindex=\"0\" data-toc-anchor=\"true\">The pre-PR checklist for layout code</h2>\n<p>Before you open a pull request, run through these five checks:</p>\n<ol>\n<li data-reveal=\"\"><strong>Axis check:</strong> Are you controlling 1 dimension (Flexbox) or 2 (Grid)? If the answer does not match the engine, fix it.</li><li data-reveal=\"\"><strong>Gap, not margin:</strong> All item spacing should be <code>gap</code>, not <code>margin</code>. Gap is the layout engine's responsibility. Margin is the element's.</li><li data-reveal=\"\"><strong>Intrinsic flexibility:</strong> Card grids should use <code>minmax()</code> with <code>auto-fill</code>/<code>auto-fit</code>, not brittle media query breakpoints.</li><li data-reveal=\"\"><strong>Subgrid alignment:</strong> Are internal sub-elements (headings, footers) aligned across adjacent cards? If yes, <code>grid-template-rows: subgrid</code> is the one-line answer.</li><li data-reveal=\"\"><strong>Overflow guard:</strong> Every flex item with text truncation or fluid media must have <code>min-width: 0</code>.</li></ol>\n<h2 id=\"the-platform-is-the-framework\" tabindex=\"0\" data-toc-anchor=\"true\">The platform is the framework</h2>\n<p>Here is the thing Rahul's article implies but does not say outright: <strong>the CSS layout engine is now complete.</strong> Between Flexbox, Grid, container queries, subgrid, native masonry (<code>grid-template-rows: masonry</code>), and the <code>align-content</code> single-line upgrade, there is no layout problem that requires a third-party abstraction layer.</p>\n<p>You do not need a layout framework. You do not need a grid system library. You do not need Bootstrap's grid, Tailwind's grid utilities, or any <code>col-md-6</code>-style abstraction. The browser ships two layout engines that together cover the entire problem space. Learn them. Use them directly. Delete the middleman.</p>\n<p>The difference between \"CSS is hard\" and \"CSS is deterministic\" is not talent. It is understanding the mechanical difference between a 1D content-first distribution engine and a 2D structure-first constraint solver. Once you internalize that distinction, every layout decision becomes obvious.</p>\n<p>The war is over. Grid and Flexbox won. Now go build.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://medium.com/@rahulkaklotar/css-grid-vs-flexbox-in-2026-stop-guessing-start-knowing-38ca7f0f9dad?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Rahul Kaklotar, \"CSS Grid vs Flexbox in 2026 - Stop Guessing, Start Knowing\"</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: CSS Flexible Box Layout</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: CSS Grid Layout</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: CSS Container Queries</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout/Subgrid?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN: Subgrid</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/TR/css-grid-3/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS Grid Layout Module Level 3 (Masonry)</a></li></ul>\n<hr>\n<p>This article was written with AI support and reviewed by the author.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-08-06-css-grid-vs-flexbox-2026.png",
      "date_published": "2026-08-06T00:00:00.000Z",
      "tags": [
        "css",
        "frontend",
        "layout",
        "web-standards",
        "performance"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-08-03-bury-utility-css-frameworks.html",
      "url": "https://dout.dev/posts/2026-08-03-bury-utility-css-frameworks.html",
      "title": "It Is Time to Bury Utility CSS Frameworks (Yes, Even Tailwind)",
      "summary": "The platform moved. Tailwind didn't get the memo.",
      "content_html": "<h2 id=\"the-platform-moved-tailwind-didn-t-get-the-memo\" tabindex=\"0\" data-toc-anchor=\"true\">The platform moved. Tailwind didn't get the memo.</h2>\n<p>Italo Baeza Cabrera wrote a measured piece asking whether it is <a href=\"https://medium.com/@elbaeza/is-it-time-to-bury-tailwind-css-726363fe595c?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">time to move on from Tailwind CSS</a>. His conclusion was careful: Tailwind is overkill for presentational sites, still useful for high-interactivity apps when you build a proper three-layer design system on top of it. I think Italo was too generous.</p>\n<p>The question is not whether Tailwind is <em>sometimes useful</em>. The question is whether it is <em>still the best tool for the job it claims to solve</em>. And the answer, in 2026, is no. The platform absorbed every problem Tailwind was built to solve, and the framework's remaining value proposition is a build step you don't need, a class-name tax you shouldn't pay, and a specificity model that fights the cascade instead of working with it.</p>\n<p>This is not a \"Tailwind sucks\" post. Tailwind was brilliant in 2017. The problem is that 2017 was almost a decade ago, and CSS is not the same language anymore.</p>\n<h2 id=\"what-tailwind-solved-and-why-it-doesn-t-need-solving-anymore\" tabindex=\"0\" data-toc-anchor=\"true\">What Tailwind solved, and why it doesn't need solving anymore</h2>\n<p>Let's be fair. When Tailwind appeared, it addressed four genuine pain points:</p>\n<ol>\n<li data-reveal=\"\"><strong>Naming things is hard.</strong> No more arguing whether this is a <code>.card</code>, <code>.container</code>, <code>.panel</code>, or <code>.box</code>. Utility classes sidestep naming entirely.</li><li data-reveal=\"\"><strong>Design tokens were inaccessible.</strong> <code>#876876</code> means nothing. <code>bg-red-500</code> means something. A constrained palette of values kept teams consistent.</li><li data-reveal=\"\"><strong>CSS had no reusable primitives.</strong> Functions, mixins, nesting-these required Sass or PostCSS. Tailwind bundled the preprocessor <em>and</em> the design system in one package.</li><li data-reveal=\"\"><strong>Onboarding was expensive.</strong> Every project had its own CSS conventions. Tailwind was a shared language that transferred across jobs.</li></ol>\n<p>In 2017, every one of those was a real problem. In 2026, none of them are.</p>\n<h3 id=\"naming-is-solved-by-the-platform-not-by-refusing-to-name-things\" tabindex=\"0\" data-toc-anchor=\"true\">Naming is solved by the platform, not by refusing to name things</h3>\n<p>The Tailwind solution to naming was to eliminate names: don't call it a card, call it <code>rounded-lg bg-white p-4 shadow-md</code>. This works until you have twenty cards on a page, each with the same fourteen-class string repeated identically. At that point, you have not eliminated the abstraction-you have just refused to give it a name, and now the abstraction lives as a <em>string literal duplicated across your markup</em> instead of as a single CSS class.</p>\n<p>Native CSS gives you better options:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Option A: a semantic class backed by custom properties */\n.card {\n  border-radius: var(--radius-lg);\n  background: var(--color-white);\n  padding: var(--spacing-4);\n  box-shadow: var(--shadow-md);\n}\n\n/* Option B: an attribute selector backed by custom properties */\n[data-component=\"card\"] {\n  border-radius: var(--radius-lg);\n  background: var(--color-white);\n  padding: var(--spacing-4);\n  box-shadow: var(--shadow-md);\n}</code></pre><p>The attribute selector approach has a property that utility classes lack: <strong>the selector itself carries semantic meaning.</strong> <code>class=\"rounded-lg bg-white p-4 shadow-md\"</code> tells you what the element <em>looks like</em>. <code>[data-component=\"card\"]</code> tells you what the element <em>is</em>. When you read the HTML, you understand the document structure. When you read the CSS, you find the component definition in one place, not scattered across every instance in the markup.</p>\n<h3 id=\"design-tokens-are-custom-properties-and-they-work-without-a-build-step\" tabindex=\"0\" data-toc-anchor=\"true\">Design tokens are custom properties, and they work without a build step</h3>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --color-primary: oklch(0.55 0.2 260);\n  --color-primary-hover: oklch(0.55 0.2 260 / 0.8);\n  --radius-sm: 0.25rem;\n  --radius-md: 0.5rem;\n  --radius-lg: 0.75rem;\n  --shadow-sm: 0 1px 3px oklch(0 0 0 / 0.1);\n  --shadow-md: 0 4px 6px oklch(0 0 0 / 0.1);\n  --spacing-2: 0.5rem;\n  --spacing-4: 1rem;\n}</code></pre><p>Every one of Tailwind's <code>@theme</code> tokens maps directly to a custom property. The difference is that custom properties work in every browser, require zero build steps, are debuggable in DevTools, and can be changed at runtime. Tailwind's <code>@theme</code> block compiles down to… custom properties. You are running a build step to generate something the browser already supports natively.</p>\n<h3 id=\"the-preprocessor-is-obsolete\" tabindex=\"0\" data-toc-anchor=\"true\">The preprocessor is obsolete</h3>\n<p>Italo mentions that \"pairing Tailwind CSS with a CSS processor is the only way to make the framework extend its legs.\" That is an admission of failure. If your utility framework requires a preprocessor to be useful, and the preprocessor features you need (nesting, mixins, color manipulation) are now native to CSS, then the utility framework is a middleman. Cut the middleman.</p>\n<p>What Sass gave us → what CSS absorbed:</p>\n<ul>\n<li data-reveal=\"\"><code>$variable</code> → <code>--custom-property</code></li><li data-reveal=\"\"><code>darken($color, 10%)</code> → <code>color-mix(in srgb, var(--color), black 10%)</code></li><li data-reveal=\"\"><code>@mixin</code> → CSS Functions and Mixins Module (working draft, shipping behind flags)</li><li data-reveal=\"\">Nesting → native CSS nesting (<code>&amp;</code> syntax)</li><li data-reveal=\"\"><code>@if</code> / <code>@else</code> → CSS <code>if()</code> function (shipping in Chrome)</li></ul>\n<p>The pipeline that was once <code>Sass → PostCSS → Tailwind → Autoprefixer → CSS</code> is now <code>CSS</code>. One step. Zero dependencies.</p>\n<h2 id=\"the-class-name-tax-why-utility-classes-are-wasteful-at-scale\" tabindex=\"0\" data-toc-anchor=\"true\">The class-name tax: why utility classes are wasteful at scale</h2>\n<p>Every Tailwind class on an element is a string the browser must parse, intern, and match against the stylesheet. Twenty classes on an element means twenty lookups. Now multiply by a hundred elements on a page. Now multiply by every page on your site.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- This element carries fourteen class lookups --&gt;\n&lt;div class=\"flex flex-col gap-4 p-6 bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200\"&gt;</code></pre><p>Compare:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- This element carries one attribute lookup --&gt;\n&lt;div data-component=\"card\"&gt;</code></pre><p>The CSS for the second approach:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>[data-component=\"card\"] {\n  display: flex;\n  flex-direction: column;\n  gap: var(--spacing-4);\n  padding: var(--spacing-6);\n  background: var(--color-white);\n  border-radius: var(--radius-lg);\n  box-shadow: var(--shadow-md);\n  border: 1px solid var(--color-gray-200);\n  transition: box-shadow 200ms ease;\n}\n\n[data-component=\"card\"]:hover {\n  box-shadow: var(--shadow-lg);\n}</code></pre><p>Same visual result. One attribute selector instead of fourteen classes. The browser does one lookup, not fourteen. The HTML is readable. The CSS is maintainable. The abstraction has a name.</p>\n<h3 id=\"attribute-selectors-are-not-a-hack-they-are-a-feature\" tabindex=\"0\" data-toc-anchor=\"true\">Attribute selectors are not a hack-they are a feature</h3>\n<p>Every time I suggest <code>[data-component=\"card\"]</code> over <code>class=\"card\"</code>, someone objects that attribute selectors are \"slower\" than class selectors. Let me kill this myth: the performance difference between a class selector and an attribute selector is <strong>below the noise floor</strong> for any real application. Browsers optimize both to the same order of magnitude. The thing that actually slows down style resolution is <em>the number of rules and the complexity of the cascade</em>, not the selector type.</p>\n<p>What attribute selectors give you that classes do not:</p>\n<ul>\n<li data-reveal=\"\"><strong>State as style.</strong> <code>[aria-expanded=\"true\"]</code>, <code>[aria-current=\"page\"]</code>, <code>[data-loading]</code>-these selectors tie visual state directly to semantic state. When your accessible markup is correct, your styles are automatically correct too. No <code>class=\"is-active is-expanded\"</code> sync bugs.</li><li data-reveal=\"\"><strong>A flat, predictable specificity.</strong> Attribute selectors have the same specificity as class selectors (<code>0,1,0</code>). They don't create specificity wars. Combined with <code>@layer</code>, you control the cascade explicitly.</li><li data-reveal=\"\"><strong>Self-documenting markup.</strong> <code>&lt;nav data-component=\"breadcrumb\" aria-label=\"Breadcrumb\"&gt;</code> tells you everything. <code>&lt;nav class=\"flex gap-2 text-sm text-gray-500\"&gt;</code> tells you nothing about <em>what</em> it is, only <em>how it looks right now</em>.</li><li data-reveal=\"\"><strong>No naming collision.</strong> <code>[data-component=\"card\"]</code> will never conflict with a third-party library's <code>.card</code> class. The <code>data-</code> namespace is yours.</li></ul>\n<h2 id=\"specificity-without-the-war-layer-fixes-what-utility-frameworks-broke\" tabindex=\"0\" data-toc-anchor=\"true\">Specificity without the war: <code>@layer</code> fixes what utility frameworks broke</h2>\n<p>Tailwind's approach to specificity is brute force: generate every utility at the same specificity level (<code>0,1,0</code> for classes) and let the cascade's source order resolve conflicts. When that fails-when a component style needs to override a utility-you have two options: <code>!important</code> (which Tailwind uses liberally) or the <code>@layer</code> directive (which Tailwind added in v3 to fix the mess <code>!important</code> created).</p>\n<p>Native CSS gives you <code>@layer</code> without the framework:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>@layer reset, tokens, utilities, components, overrides;\n\n/* reset layer: lowest priority */\n@layer reset {\n  *, *::before, *::after { box-sizing: border-box; margin: 0; }\n}\n\n/* tokens layer: design decisions */\n@layer tokens {\n  :root {\n    --radius-md: 0.5rem;\n    --color-primary: oklch(0.55 0.2 260);\n  }\n}\n\n/* utilities layer: single-purpose, composable */\n@layer utilities {\n  .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }\n  .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n}\n\n/* components layer: your design system */\n@layer components {\n  [data-component=\"card\"] { /* ... */ }\n  [data-component=\"btn\"] { /* ... */ }\n}\n\n/* overrides layer: one-off adjustments, always wins */\n@layer overrides {\n  .home-page [data-component=\"card\"] { /* special case */ }\n}</code></pre><p>The cascade is explicit. You know exactly what overrides what. No <code>!important</code>, no framework-mandated specificity battles, no generated CSS that weighs 300 KB. The <code>@layer</code> order is declared once, and every rule in every file respects it. This is what Tailwind's <code>@layer base</code>, <code>@layer components</code>, <code>@layer utilities</code> was trying to be-except you control it, the browser enforces it, and no build step is required.</p>\n<h2 id=\"the-build-step-is-a-liability-not-a-feature\" tabindex=\"0\" data-toc-anchor=\"true\">The build step is a liability, not a feature</h2>\n<p>Tailwind requires a build step. Full stop. You need PostCSS, you need the Tailwind plugin, you need the content paths configured, you need the <code>@tailwind</code> directives processed. This adds:</p>\n<ul>\n<li data-reveal=\"\"><strong>A dependency chain.</strong> Tailwind depends on PostCSS, which depends on Node.js, which depends on your operating system's ability to run Node. Every link in this chain is a potential breakage point.</li><li data-reveal=\"\"><strong>Dev server latency.</strong> Every class change requires a rebuild. The Tailwind JIT engine is fast, but \"fast\" is still slower than \"instant.\" Native CSS changes apply on save with zero processing.</li><li data-reveal=\"\"><strong>Debugging indirection.</strong> The class you wrote (<code>bg-red-500</code>) is not the CSS the browser applies. What the browser sees is a generated rule somewhere in a 200 KB stylesheet. DevTools can map it back, but you have added an indirection layer between author intent and browser reality.</li><li data-reveal=\"\"><strong>An onboarding barrier.</strong> A new team member must understand Tailwind's class naming conventions, the configuration file, the content scanning system, the <code>@layer</code> directives, the <code>@apply</code> escape hatch, the <code>theme()</code> function, and the arbitrary value syntax (<code>w-[327px]</code>). Compare: to work with native CSS, they need to understand CSS.</li></ul>\n<p>The build step made sense when it was doing real work: transpiling modern syntax, removing unused styles, optimizing for production. In 2026, every browser you care about supports modern syntax natively. Tree-shaking CSS matters less with HTTP/2 multiplexing and <code>@layer</code>-organized stylesheets that are small by design. The build step is now a ritual performed because the framework requires it, not because the output needs it.</p>\n<h2 id=\"the-apply-escape-hatch-admits-the-framework-failed\" tabindex=\"0\" data-toc-anchor=\"true\">The <code>@apply</code> escape hatch admits the framework failed</h2>\n<p>Tailwind's <code>@apply</code> directive exists because even Tailwind's creators understand that repeating the same fourteen classes on every card instance is untenable:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.card {\n  @apply rounded-lg bg-white p-4 shadow-md;\n}</code></pre><p>This is the framework admitting that <strong>classes are a better abstraction than utility strings for reusable components.</strong> But <code>@apply</code> combines the worst of both worlds: you still need the build step, you still pay the specificity tax, and now you have a non-standard syntax (<code>@apply</code>) that will never work in a browser.</p>\n<p>The native equivalent is shorter, portable, and requires zero tooling:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.card {\n  border-radius: var(--radius-lg);\n  background: var(--color-white);\n  padding: var(--spacing-4);\n  box-shadow: var(--shadow-md);\n}</code></pre><p>Custom properties are the variables. The component class is the abstraction. The cascade handles the rest. What exactly did <code>@apply</code> add to this equation? A build step and a dependency.</p>\n<h2 id=\"ai-makes-utility-frameworks-even-less-necessary\" tabindex=\"0\" data-toc-anchor=\"true\">AI makes utility frameworks even less necessary</h2>\n<p>Every major LLM writes standard CSS fluently. They do not need a cheat sheet for Tailwind class names because they were trained on the entire corpus of web development, which is overwhelmingly standard CSS. When an LLM generates <code>class=\"flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors\"</code>, it is hallucinating Tailwind syntax from memorized patterns-and it gets it wrong approximately as often as it gets it right because the training data is noisy.</p>\n<p>When the same LLM generates:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.btn-primary {\n  display: flex;\n  align-items: center;\n  gap: var(--spacing-2);\n  padding: var(--spacing-2) var(--spacing-4);\n  background: var(--color-primary);\n  color: var(--color-white);\n  border-radius: var(--radius-md);\n  transition: background 200ms ease;\n}\n.btn-primary:hover { background: var(--color-primary-hover); }</code></pre><p>…the output is verifiable. Every property is a standard CSS feature. Every value is a standard CSS value. You can read it, test it, diff it, and debug it with the same tools you use for the rest of your codebase. The LLM is generating standard web platform code, not framework-specific incantations.</p>\n<p>This matters because AI-assisted development shifts the bottleneck from <em>code generation speed</em> to <em>code verification speed</em>. Standard CSS verifies faster than framework CSS because there are fewer layers to audit, fewer dependencies to trust, and fewer build artifacts to inspect.</p>\n<h2 id=\"when-do-utility-classes-still-make-sense\" tabindex=\"0\" data-toc-anchor=\"true\">When do utility classes still make sense?</h2>\n<p>If you read this far thinking I am about to say \"never,\" here is the nuance.</p>\n<p>A small set of genuinely single-purpose utility classes still earns its keep. <code>sr-only</code>, <code>truncate</code>, <code>visually-hidden</code>-these are one-property classes that express a single concept and never change. They are closer to \"design tokens in selector form\" than to \"a framework-level styling strategy.\"</p>\n<p>The key distinction is <strong>who defines the utility and how many there are.</strong> A project-specific <code>@layer utilities</code> block with fifteen classes, curated by your team, matching your design system, is a reasonable abstraction. A third-party framework that generates ten thousand classes, most of which you will never use, is a dependency tax you should refuse to pay.</p>\n<p>The line is: <strong>if your utility class is a thin wrapper around a custom property, it is a design token, and that is fine.</strong> <code>truncate</code> wraps <code>text-overflow: ellipsis</code>. <code>sr-only</code> wraps an accessible hiding pattern. These are not \"Tailwind lite.\" These are platform abstractions that happen to be expressed as classes.</p>\n<h2 id=\"the-three-layer-system-without-the-framework\" tabindex=\"0\" data-toc-anchor=\"true\">The three-layer system without the framework</h2>\n<p>Italo described his three-layer approach: utilities → elements → blocks, built on Tailwind's <code>@theme</code>. Here is the same system in native CSS, with zero dependencies:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* --- Layer 1: Semantic tokens (the @theme replacement) --- */\n:root {\n  --color-primary: oklch(0.55 0.2 260);\n  --color-primary-hover: oklch(0.55 0.2 260 / 0.85);\n  --color-muted: oklch(0.7 0.01 260);\n  --color-bg: oklch(1 0 0);\n  --radius-sm: 0.25rem;\n  --radius-md: 0.5rem;\n  --radius-lg: 0.75rem;\n  --shadow-sm: 0 1px 3px oklch(0 0 0 / 0.1);\n  --shadow-md: 0 4px 6px oklch(0 0 0 / 0.1);\n  --shadow-neon: 0 0 12px oklch(0.7 0.2 260 / 0.5);\n  --spacing-2: 0.5rem;\n  --spacing-4: 1rem;\n  --spacing-6: 1.5rem;\n}\n\n/* --- Layer 2: Elements (the .card, .btn replacement) --- */\n[data-element=\"card\"] {\n  border-radius: var(--radius-lg);\n  background: var(--color-bg);\n  padding: var(--spacing-4);\n  box-shadow: var(--shadow-sm);\n}\n\n[data-element=\"btn\"] {\n  display: inline-flex;\n  align-items: center;\n  gap: var(--spacing-2);\n  padding: var(--spacing-2) var(--spacing-4);\n  border-radius: var(--radius-md);\n  background: var(--color-primary);\n  color: var(--color-white);\n  border: none;\n  cursor: pointer;\n  transition: background 200ms ease;\n}\n\n[data-element=\"btn\"]:hover {\n  background: var(--color-primary-hover);\n}\n\n/* --- Layer 3: Blocks (the .testimony, .pricing-table replacement) --- */\n[data-block=\"testimony\"] {\n  display: grid;\n  gap: var(--spacing-4);\n  padding: var(--spacing-6);\n  border-radius: var(--radius-lg);\n  background: var(--color-bg);\n  box-shadow: var(--shadow-md);\n}\n\n[data-block=\"testimony\"] &gt; [data-element=\"card\"] {\n  box-shadow: none;\n  background: transparent;\n}</code></pre><p>This is Italo's exact architecture-semantic tokens feed elements, elements compose into blocks-expressed in 100% standard CSS. Change <code>--color-primary</code> in one place, every button, card, and block updates. No build step. No <code>@apply</code>. No framework churn. The platform does the work.</p>\n<p>The neon shadow example from Italo's article? One line change:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>--shadow-sm: 0 0 12px oklch(0.7 0.2 260 / 0.5);</code></pre><p>Done. Every element consuming <code>var(--shadow-sm)</code> updates. This is the power of the cascade, not the power of Tailwind.</p>\n<h2 id=\"the-bootstrapy-problem-is-structural-not-cosmetic\" tabindex=\"0\" data-toc-anchor=\"true\">The \"bootstrapy\" problem is structural, not cosmetic</h2>\n<p>Italo ends with a concern I want to amplify: Tailwind risks becoming what Bootstrap became-a visual signature that screams \"I was built with X.\" Every Tailwind site starts from the same palette, the same spacing scale, the same shadow tokens. Customizing the <code>@theme</code> helps, but customization requires time and expertise that the \"just use the defaults\" pitch actively discourages.</p>\n<p>Agencies that sell visual differentiation should be terrified of this. If your landing page looks like every other Tailwind landing page, your client paid for a template with their logo on it. Native CSS, by contrast, has no default aesthetic. Every property starts empty. The design is yours to define, not yours to override.</p>\n<p>This is not an accident. It is a consequence of off-the-shelf design systems: they optimize for <em>consistency within a project</em> at the cost of <em>differentiation between projects</em>. And in a market where AI can generate a Tailwind landing page in thirty seconds, differentiation is the only thing left to sell.</p>\n<h2 id=\"the-bottom-line\" tabindex=\"0\" data-toc-anchor=\"true\">The bottom line</h2>\n<p>Tailwind CSS was the right answer to the wrong question. The question was: \"how do we make CSS productive given that CSS is missing core features?\" The answer in 2017 was a utility class framework. The answer in 2026 is: \"CSS is no longer missing those features.\"</p>\n<p>Use custom properties for design tokens. Use <code>@layer</code> for cascade control. Use <code>[data-component]</code> selectors for reusable abstractions. Use native nesting for readability. Use <code>:has()</code> for parent-aware styling. Use <code>color-mix()</code> for derived colors. Use <code>clamp()</code> for fluid sizing.</p>\n<p>Delete the build step. Delete the dependency. Delete the class-name tax.</p>\n<p>The platform caught up. Your stack should catch up too.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://medium.com/@elbaeza/is-it-time-to-bury-tailwind-css-726363fe595c?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Italo Baeza Cabrera, \"Is it time to bury Tailwind CSS?\"</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/--*?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS Custom Properties</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@layer?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS Cascade Layers (<code>@layer</code>)</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS Nesting</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/:has?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS <code>:has()</code> selector</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS <code>color-mix()</code> function</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/TR/css-mixins-1/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">W3C CSS Functions and Mixins Module</a></li><li data-reveal=\"\"><a href=\"https://developer.chrome.com/blog/if-article?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS <code>if()</code> function</a></li></ul>\n<hr>\n<p>This article was written with AI support and reviewed by the author.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-08-03-bury-utility-css-frameworks.png",
      "date_published": "2026-08-03T00:00:00.000Z",
      "tags": [
        "css",
        "tailwind",
        "frontend",
        "web-standards",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-30-vanilla-js-plus-llm-is-the-only-way.html",
      "url": "https://dout.dev/posts/2026-07-30-vanilla-js-plus-llm-is-the-only-way.html",
      "title": "Vanilla JS + LLM Is the Only Way Forward (The Frameworks Did Their Job - Now Let Them Rest)",
      "summary": "The industry ran a forty-four-meter circle and landed in your living room",
      "content_html": "<h2 id=\"the-industry-ran-a-forty-four-meter-circle-and-landed-in-your-living-room\" tabindex=\"0\" data-toc-anchor=\"true\">The industry ran a forty-four-meter circle and landed in your living room</h2>\n<p>David Poblador's <a href=\"https://davidpoblador.com/deep-dives/what-happened-to-the-frontend/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">What Happened to the Frontend</a> is the best map of the last two decades I have seen. Read it if you haven't. The thesis at bedrock is worth quoting:</p>\n<blockquote>\n<p>\"After two decades and forty-four meters of build tools and bundlers and hydration schemes, the industry sprinted in a giant circle and arrived, slightly out of breath, back at something that looks an awful lot like the file you uploaded over FTP.\"</p>\n</blockquote>\n<p>He is right. The frontier of 2026 <em>is</em> the thing we had in 2008: server-rendered HTML, minimal JavaScript, the platform doing the heavy lifting. But David stops at describing the pendulum swing. I want to name the mechanism that made it inevitable, because once you see it, the next step is obvious.</p>\n<p><strong>Frameworks were never the destination. They were the W3C's R&amp;D department - a distributed, global, unpaid prototyping lab that explored every idea the platform needed, crashed on the ones that didn't work, and handed the survivors to the standards bodies.</strong> Now the standards bodies have finished their homework. And a second force - large language models that write JavaScript as fluently as they write English - just made the framework layer structurally unnecessary for a huge and growing class of work.</p>\n<p>This is not \"frameworks bad.\" This is \"frameworks completed their mission. Let them rest.\"</p>\n<hr>\n<h2 id=\"how-the-playground-worked-every-framework-was-a-w3c-prototype\" tabindex=\"0\" data-toc-anchor=\"true\">How the playground worked: every framework was a W3C prototype</h2>\n<p>The pattern is so consistent it is almost boring once you see it. Someone hits a wall with the platform. They build a library to work around it. The library becomes popular. The W3C (or TC39, or WHATWG) watches, learns, standardises. The library's reason for existing evaporates.</p>\n<h3 id=\"jquery-queryselector-fetch-classlist-array-from\" tabindex=\"0\" data-toc-anchor=\"true\">jQuery → <code>querySelector</code>, <code>fetch</code>, <code>classList</code>, <code>Array.from</code></h3>\n<p>jQuery solved real pain: the DOM API was verbose, inconsistent across browsers, and missing common operations. Every <code>$(\"#cart\").toggleClass(\"active\")</code> was a prayer that you weren't on IE6.</p>\n<p>What the W3C absorbed: <code>document.querySelector</code> and <code>querySelectorAll</code> (Selectors API, 2013), <code>element.classList</code> (2010, 2014 across browsers), <code>fetch</code> (2015, replacing <code>XMLHttpRequest</code>), <code>Array.from</code>, <code>Object.assign</code>, <code>Array.prototype.includes</code>. What remains of jQuery's reason to exist? Essentially nothing. Nobody starts a jQuery project today, and the sites still running it do so because they stopped evolving in 2014.</p>\n<h3 id=\"react-the-platform-learned-components-reactivity-and-declarative-ui\" tabindex=\"0\" data-toc-anchor=\"true\">React → the platform learned components, reactivity, and declarative UI</h3>\n<p>React's big idea - describe the UI as a function of state, let the runtime reconcile the difference - was genuinely new in 2013. The Virtual DOM was the implementation strategy, not the insight. The insight was <em>declarative UI</em>.</p>\n<p>What the W3C and TC39 absorbed:</p>\n<ul>\n<li data-reveal=\"\"><strong>Web Components</strong> (Custom Elements v1, 2016; Shadow DOM, 2018) gave the platform a native component model. <code>customElements.define(\"my-button\", MyButton)</code> - no framework required.</li><li data-reveal=\"\"><strong>Signals</strong> (TC39 proposal, Stage 2 as of 2025, shipping in Solid.js, Preact Signals, Angular's signals, and now progressing through standards) are the final abstraction of React's state → UI pipeline. A signal is a value that knows who depends on it. You change the value. The dependent UI updates. That is the entire reactive loop, expressed as a language primitive.</li><li data-reveal=\"\"><strong>Declarative Shadow DOM</strong> (2023) lets you render Shadow DOM on the server without JavaScript.</li><li data-reveal=\"\"><strong><code>&lt;template&gt;</code></strong> and <strong><code>&lt;slot&gt;</code></strong> gave us native content projection without JSX.</li></ul>\n<p>What remains of React's unique territory? Less every year. The JSX syntax will survive as a convenience, but the <em>runtime</em> - the reconciler, the fiber tree, the synthetic event system - is increasingly redundant against native APIs.</p>\n<h3 id=\"webpack-babel-es-modules-import-maps-and-the-native-build\" tabindex=\"0\" data-toc-anchor=\"true\">Webpack / Babel → ES Modules, Import Maps, and the native build</h3>\n<p>The build step was never fun. It was a workaround for a platform that didn't have modules, didn't understand modern syntax, and punished you with HTTP/1.1 round-trips for every file.</p>\n<p>What happened:</p>\n<ul>\n<li data-reveal=\"\"><strong>ES Modules</strong> (<code>import</code>/<code>export</code>, 2018 in all major browsers) ended the module fragmentation war. CommonJS, AMD, UMD - all fossils.</li><li data-reveal=\"\"><strong>Import Maps</strong> (2021) let you control module specifiers in the browser without a bundler: <code>{\"imports\": {\"lit-html\": \"https://cdn.jsdelivr.net/npm/lit-html@3/\"}}</code>. No webpack. No config.</li><li data-reveal=\"\"><strong>Native module loading</strong> in browsers (2018+) is fast enough that HTTP/2 multiplexing makes bundling less necessary for development and often for production.</li><li data-reveal=\"\"><strong>Modern JavaScript syntax</strong> (optional chaining, nullish coalescing, private fields, top-level await) is supported in every browser that matters. No Babel pass needed.</li><li data-reveal=\"\"><strong><code>type=\"module\"</code> scripts</strong> load deferred by default, scoped by module, cleanly separated. No IIFE wrapping, no global leaks.</li></ul>\n<h3 id=\"css-in-js-sass-native-css\" tabindex=\"0\" data-toc-anchor=\"true\">CSS-in-JS / Sass → native CSS</h3>\n<p>This one is almost embarrassing in retrospect. We reached for Sass, PostCSS, Styled Components, CSS Modules - all to compensate for features that CSS has now natively absorbed:</p>\n<ul>\n<li data-reveal=\"\"><strong>Nesting</strong> (2023+) - the top reason people used Sass.</li><li data-reveal=\"\"><strong>Container Queries</strong> (2022+) - responsive components without media queries against the viewport.</li><li data-reveal=\"\"><strong><code>:has()</code></strong> (2022+) - a parent selector, which Sass never even attempted. \"Style the parent if it contains a child matching X\" - the web's most-requested feature, delivered by the platform, impossible in any preprocessor.</li><li data-reveal=\"\"><strong>Cascade Layers</strong> (<code>@layer</code>, 2022+) - specificity management without <code>!important</code> screaming or BEM naming gymnastics.</li><li data-reveal=\"\"><strong>Custom Properties</strong> (2016+) - dynamic theming without a preprocessor variable. <code>color-mix()</code>, <code>light-dark()</code>, and <code>relative color syntax</code> (2023–2025) closed the remaining gaps.</li></ul>\n<h3 id=\"state-management-redux-mobx-zustand-signals-structured-clone\" tabindex=\"0\" data-toc-anchor=\"true\">State management (Redux, MobX, Zustand) → Signals + structured clone</h3>\n<p>Every JS framework built a state management story because the platform didn't have reactive primitives. Now TC39 is standardising Signals, <code>structuredClone</code> handles deep copies, and <code>BroadcastChannel</code> + <code>SharedWorker</code> covers cross-tab state sharing. The remaining use cases are genuinely application-specific, not infrastructure missing from the platform.</p>\n<h3 id=\"the-artificial-complexity-tax-and-who-s-been-collecting-it\" tabindex=\"0\" data-toc-anchor=\"true\">The artificial complexity tax (and who's been collecting it)</h3>\n<p>Guseyn, who built a complete SaaS product - <a href=\"https://instruxmusic.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">instruxmusic.com</a> - in vanilla JavaScript with Web Components, puts his finger on something I have felt for years but never articulated this cleanly:</p>\n<blockquote>\n<p>\"I tend to believe if something requires from you more lines of code and more abstractions in an abstract framework to achieve than in native technology, then maybe this framework does not really make your life easier.\"</p>\n</blockquote>\n<p>He crystallises the framework mindset into five commandments, and reading them as a list makes the pattern embarrassing:</p>\n<blockquote>\n<ol>\n<li data-reveal=\"\"><strong>NO, YOU CANNOT MANIPULATE DOM DIRECTLY</strong>, ONLY WE CAN DO THAT INSIDE OF OUR FRAMEWORK.</li><li data-reveal=\"\"><strong>NO, YOU CANNOT JUST ATTACH EVENT LISTENER TO WINDOW OBJECT.</strong> YOU NEED TO WRAP IT.</li><li data-reveal=\"\"><strong>NO, YOU CANNOT JUST USE GLOBAL STATE.</strong> USE THIRD-PARTY LIBRARY WITH FANCY FUNCTIONAL DESIGN.</li><li data-reveal=\"\"><strong>NO, YOU CANNOT USE NATIVE WEB COMPONENTS.</strong> YOU MUST USE HOOKS AND OUR LIFECYCLE WE INVENTED.</li><li data-reveal=\"\"><strong>NO, YOU CANNOT USE IMPORT MAP.</strong> YOU MUST USE A BUILD TOOL AND PREPROCESSORS.</li></ol>\n</blockquote>\n<p>Each of these is a <em>ban</em>, not a simplification. They are walls built around a captive audience. The native web platform never banned any of those things - frameworks did, because controlling the escape hatches is how frameworks retain relevance.</p>\n<p>And yet the industry swallowed it. We accepted that writing HTML meant picking from a shelf of twenty-odd templating languages (Jinja, Pug, Handlebars, Nunjucks, Mustache, EJS, Blade, Twig, ERB, Haml, Slim, Thymeleaf, Tera, Askama, Maud - and that is <em>not</em> exhaustive). We accepted that CSS needed a preprocessor (Sass, Less, Stylus, PostCSS, Myth - all solving problems the platform has since absorbed). We accepted that JavaScript was somehow not good enough and needed to be written in TypeScript, CoffeeScript, PureScript, ReasonML, Elm, ReScript, ClojureScript, or Dart. The tower of Babel was not a bug. It was a business model.</p>\n<p>React, in particular, is a masterwork of branding. The name evokes speed, dynamism, life - the opposite of what it actually does, which is interpose a ~45 KB reconciler between you and the platform. As Guseyn notes:</p>\n<blockquote>\n<p>\"It's a strong name, isn't it? It gives speed and power. It gives something dynamic, positive and full of life.\"</p>\n</blockquote>\n<p>The irony is that the fastest thing you can do is <em>nothing</em>. No framework runtime. No reconciliation. No hydration. The browser already has a rendering engine. It is extremely good at its job. You do not need to simulate it in JavaScript.</p>\n<hr>\n<h2 id=\"the-pattern-is-finished\" tabindex=\"0\" data-toc-anchor=\"true\">The pattern is finished</h2>\n<p>Look at the list above. Every major innovation of the framework era - components, reactivity, modules, bundling, styling, state - has either shipped as a native web API or is actively progressing through standards. The ones that are still framework-only are either too niche to standardise (JSX's ergonomics, which you can replicate with tagged template literals) or too new to have reached consensus yet.</p>\n<p>The frameworks served a function analogous to the DARPA of the web platform: they took risks, iterated fast, burned down bad ideas, and handed the survivors to the standards bodies. That function is not infinite. At some point the prototyping phase ends and the standardisation phase produces a platform that makes the prototypes unnecessary.</p>\n<p><strong>That point is now.</strong></p>\n<hr>\n<h2 id=\"enter-the-second-force-the-llm-writes-the-glue\" tabindex=\"0\" data-toc-anchor=\"true\">Enter the second force: the LLM writes the glue</h2>\n<p>If the platform had matured in isolation, we would still need <em>something</em> to wire components together, handle routing, manage data fetching - the application-level orchestration that no standards body will ever define because it is inherently non-standard. A decade ago that \"something\" was a framework. Today it is an LLM.</p>\n<p>Here is the crucial insight that most framework advocates miss:</p>\n<p><strong>An LLM generates JavaScript, not React. It generates HTML, not JSX. It generates CSS, not Sass. The more you lean on the platform, the better the LLM's output is - because the LLM was trained on the entire platform, not on any single framework's subset.</strong></p>\n<p>When I prompt an LLM with \"build me a product page with a cart button that updates a badge count,\" it returns:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;script type=\"module\"&gt;\n  import { signal, effect } from \"./signals.js\";\n\n  const count = signal(0);\n\n  document.querySelector(\"#add-to-cart\")\n    .addEventListener(\"click\", () =&gt; count.set(count.get() + 1));\n\n  effect(() =&gt; {\n    document.querySelector(\".cart-badge\").textContent = count.get();\n  });\n&lt;/script&gt;</code></pre><p>That's ~200 bytes of vanilla JS. It uses platform APIs (<code>querySelector</code>, <code>addEventListener</code>, <code>textContent</code>, <code>type=\"module\"</code>). It uses a lightweight signal library (which exists on a CDN, zero install). It has no build step, no JSX transform, no Virtual DOM, no hydration, no suspense boundaries, no server components, no \"use client\" directives. It works in every modern browser. It will work in every future browser. <strong>It was written by an AI in three seconds.</strong></p>\n<p>Now contrast what the same LLM generates when it's been trained on React tutorials:</p>\n<pre is=\"pix-highlighter\" data-lang=\"jsx\"><code>\"use client\";\nimport { useState, useEffect } from \"react\";\nimport { useCart } from \"@/hooks/use-cart\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\n\nexport function AddToCart({ productId }) {\n  const [count, setCount] = useState(0);\n  const { addItem } = useCart();\n\n  useEffect(() =&gt; {\n    // 27 lines of hydration safety, SSR guards,\n    // and a dependency array that will cause a lint warning\n  }, [count, productId, addItem]);\n\n  return (\n    &lt;div className=\"flex items-center gap-2\"&gt;\n      &lt;Button onClick={() =&gt; { setCount(c =&gt; c + 1); addItem(productId); }}&gt;\n        Add to Cart\n      &lt;/Button&gt;\n      &lt;Badge&gt;{count}&lt;/Badge&gt;\n    &lt;/div&gt;\n  );\n}</code></pre><p>That's ~600 bytes of source code that ships ~30 KB of framework runtime (React + ReactDOM + the component library). It requires a build step, a bundler config, a <code>tsconfig.json</code>, a <code>tailwind.config.js</code>, a <code>components.json</code>, 250,000 files in <code>node_modules</code>, and a deployment platform that understands the meta-framework of the week. The AI wrote it in three seconds too. But the AI is <em>reflecting the framework's assumptions</em>, not the platform's capabilities.</p>\n<p><strong>The difference is not about developer skill. It is about which set of defaults you carry into the prompt.</strong></p>\n<hr>\n<h2 id=\"the-browser-is-already-the-framework\" tabindex=\"0\" data-toc-anchor=\"true\"><code>\"The browser is already the framework\"</code></h2>\n<p>This is the single line from Guseyn's essay that cuts the deepest:</p>\n<blockquote>\n<p>\"The browser engine is a huge and complex piece of software that handles so much for you that the only thing you really need is just to write dynamic files with HTML, CSS and JavaScript and a browser engine glues everything for you.\"</p>\n</blockquote>\n<p>He built <a href=\"https://instruxmusic.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">instruxmusic.com</a> - a full SaaS for music schools with lesson scheduling, automated invoicing, video chat with file sharing, a brand page builder with custom domains, a booking system, dashboards, stats, messaging - using exactly this philosophy. Web Components. Import maps. Zero build step. Zero framework runtime. The whole application, backend and frontend, is vanilla JavaScript, HTML, and CSS.</p>\n<p>And here is the number that should make every framework advocate pause:</p>\n<pre is=\"pix-highlighter\"><code>Frontend only:\n-----------------------------------------------------------------\nLanguage      files     code\n-----------------------------------------------------------------\nHTML            54    17687  ← 50%+\nCSS              6     5437  ← 15%\nJavaScript     102     9252  ← 35%\n-----------------------------------------------------------------\nSUM:           162    32376\n-----------------------------------------------------------------</code></pre><p><strong>Nine thousand lines of JavaScript - frameworks included - for a complete SaaS product, running in production, serving real customers.</strong></p>\n<p>HTML is over half the codebase. CSS is 15%. The JavaScript is a <em>minority</em> of the application. This is the exact inverse of the typical React app, where the JavaScript bundle dwarfs everything else because it carries the framework runtime, the component library, the state management library, the routing library, the data fetching library, and the thousand other things you \"need\" to build a modern web app.</p>\n<p>Guseyn's approach is not an exception. It is a <em>template</em>. Custom elements, import maps, <code>&lt;template&gt;</code> for declarative rendering, native form handling with JSON, HTML caching for multi-page navigation that feels like SPA. Every technique uses the platform as designed, not as a target to be transpiled to.</p>\n<p>And the best part? He writes:</p>\n<blockquote>\n<p>\"You don't need AI to build with this approach. Or at very least you definitely don't have to spend so many tokens.\"</p>\n</blockquote>\n<p>Because when your codebase is 50% HTML and 15% CSS, the surface area the LLM needs to cover is small. The LLM writes the JavaScript glue - the event listeners, the API calls, the custom element lifecycle. The HTML and CSS are already declarative. They <em>are</em> the spec.</p>\n<hr>\n<h2 id=\"the-whole-stack-collapses-to-this\" tabindex=\"0\" data-toc-anchor=\"true\">The whole stack collapses to this</h2>\n<p>A 2026 frontend built for vanilla JS and LLM collaboration:</p>\n<pre is=\"pix-highlighter\"><code>Project root:\n├── index.html          ← server-rendered or static, no build\n├── styles.css          ← native CSS, nesting, layers, container queries\n├── scripts/\n│   ├── main.js         ← type=\"module\", imports from CDN\n│   ├── components/\n│   │   ├── cart.js     ← custom elements + signals\n│   │   ├── product.js\n│   │   └── search.js\n│   └── utils/\n│       ├── signals.js  ← ~50 lines, or import from CDN\n│       └── api.js      ← fetch, no client state library\n├── assets/\n└── _headers            ← cache policy, CSP</code></pre><p>Zero <code>package.json</code>. Zero <code>node_modules</code>. Zero build steps. Zero <code>tsconfig.json</code>. Zero framework-specific CI config. Zero lockfiles. Zero migrations every two years. Zero of the 250,000 files that a blank <code>create-next-app</code> deposits on your filesystem.</p>\n<p>Deployment: copy to a CDN. Or GitHub Pages. Or Netlify with <code>--no-build</code>. Or a single Nginx folder.</p>\n<p>Performance: whatever the platform gives you, which is a lot - native ESM is deferred, CSS <code>content-visibility: auto</code> handles virtualisation, <code>loading=\"lazy\"</code> on images, <code>fetchpriority=\"high\"</code> on the hero, <code>dns-prefetch</code> and <code>preconnect</code> for third parties. No framework runtime at all. Every byte is yours.</p>\n<p><strong>You cannot optimise faster than zero.</strong></p>\n<hr>\n<h2 id=\"the-objections-answered-quickly\" tabindex=\"0\" data-toc-anchor=\"true\">The objections (answered quickly)</h2>\n<p><strong>\"You need TypeScript for any serious project.\"</strong></p>\n<p>TypeScript's value is real. But <code>// @ts-check</code> plus JSDoc annotations gives you type checking on vanilla JS <em>without a build step</em>. Or use <code>tsc --noEmit</code> as a lint pass - it checks types and emits nothing. The LLM will generate the JSDoc annotations. You get autocomplete, you get type errors at save time, you get zero compilation overhead. Is it as ergonomic as TSX with full type inference? No. Does it cover 90% of the value? Yes.</p>\n<p><strong>\"Web Components are ugly/hard/not ready.\"</strong></p>\n<p>This was true in 2018. Custom Elements v1 is shipped in every browser. Declarative Shadow DOM is shipped. The HTML template element, slots, <code>part</code> and <code>exportparts</code> for styling - all shipped. The developer experience is not JSX-level ergonomic, but it is <em>platform-level stable</em>, which means any code you write today will work in 2035. The LLM handles the boilerplate. Lit and Shoelace prove that the ergonomics can be excellent.</p>\n<p><strong>\"You still need a framework for a large app.\"</strong></p>\n<p>Define \"large.\" The largest frontend apps in the world - Google Docs, Figma, VS Code - are not built with React. They are built with platform APIs, custom rendering pipelines, and carefully owned abstractions, because the framework would get in the way at that scale. If your app is smaller than Figma, you almost certainly don't need a framework. If your app <em>is</em> Figma, a framework is the last thing you want.</p>\n<p><strong>\"You lose the ecosystem.\"</strong></p>\n<p>The ecosystem of 2026 is mostly a liability. Every shadcn/ui component is copy-pasted into your repo anyway. Every Tailwind utility is a CSS rule you could write yourself. Every React hook is a function you could write in vanilla JS. The ecosystem is a crutch that became a cage. The LLM <em>is</em> your ecosystem now - it has memorised every good pattern from every library, and it generates them on demand without the import.</p>\n<p><strong>\"Vanilla JavaScript does not have a structure. It does not scale.\"</strong></p>\n<p>This is the most persistent objection, and Guseyn dismantles it with a single number:</p>\n<blockquote>\n<p><strong>9,252 lines of JavaScript</strong> - frameworks included - for a complete SaaS product with billing, video chat, booking, dashboards, and a brand page builder.</p>\n</blockquote>\n<p>Let that sink in. A production SaaS, running today, serving real music schools, with less JS than a single <code>node_modules</code> dependency tree walk. And the codebase is 50%+ HTML, because the structure is not in the JavaScript - the structure is in the <em>HTML</em>. Custom elements give you a component model. Import maps give you module resolution. The browser gives you the runtime. The framework you were told you \"need\" for structure was actually the thing generating most of the code you are maintaining.</p>\n<blockquote>\n<p>\"Sure, it is messy. You know why? Because for the last 20 years, instead of solving real issues and making good decisions on how to simplify code, we just invented so many abstractions, languages and tools just to avoid the problem instead of solving it.\"</p>\n</blockquote>\n<p>The structure argument is a self-fulfilling prophecy. Frameworks are necessary because frameworks are the only thing people learn. The platform is dismissed as unstructured because nobody teaches platform architecture anymore. But Guseyn's numbers prove it: the platform scales. It scales to a six-figure SaaS without a framework.</p>\n<hr>\n<h2 id=\"what-frameworks-gave-us-say-thank-you-then-let-go\" tabindex=\"0\" data-toc-anchor=\"true\">What frameworks gave us (say thank you, then let go)</h2>\n<p>This is not a hit piece on the people who built the frameworks. React literally changed how we think about UI. Vue's progressive design made frontend development accessible to millions. Svelte showed that compilation was a viable strategy. Astro proved that shipping less JavaScript was not a regression but an advance. Every framework team pushed the platform forward by building things the platform didn't have yet.</p>\n<p><strong>The debt is paid.</strong> The platform now has the APIs that were missing. The LLM now writes the orchestration that the framework used to supply. The node_modules directory - that two-hundred-megabyte tax on starting a project - is structurally optional for the first time since 2012.</p>\n<p>You can keep using React. You can keep building Next.js apps. Nothing will stop you. The tools will continue to exist, maintained by talented people and an ecosystem that has learned to profit from complexity. But the <em>justification</em> for starting a new project on a framework is evaporating. The defaults have shifted. The platform is mature. The AI speaks platform JavaScript, not React JavaScript, unless you teach it otherwise.</p>\n<p>The frameworks did their job. They ran the experiments. They crashed the prototypes. They handed the survivors to the W3C. The standards bodies standardised. The browsers shipped. The LLMs memorised.</p>\n<p><strong>The era of the framework was a necessary, productive, temporary phase of web platform evolution. It is over.</strong></p>\n<hr>\n<h2 id=\"what-this-looks-like-in-practice\" tabindex=\"0\" data-toc-anchor=\"true\">What this looks like in practice</h2>\n<p>Here is a concrete workflow that works today, on this blog, without a framework:</p>\n<ol>\n<li data-reveal=\"\">Write markdown in <code>data/posts/</code>.</li><li data-reveal=\"\">A tiny SSG (mine is under 500 lines) runs at build time: parses frontmatter, renders HTML through a template engine, writes flat files to <code>src/posts/</code>.</li><li data-reveal=\"\">The templates are HTML with a small <code>{% block %}</code> syntax - no JSX, no components, just template inheritance.</li><li data-reveal=\"\">Interactivity is added via <code>&lt;script type=\"module\"&gt;</code> in the HTML file, importing from CDN sources where needed.</li><li data-reveal=\"\">CSS is native - nesting, layers, custom properties, container queries. No preprocessor, no PostCSS plugins I don't control.</li><li data-reveal=\"\">The LLM (Claude, Cursor, Copilot) generates the boilerplate: the SSG pipeline, the template tags, the interactive enhancements, the meta tags, the structured data, the accessibility attributes. I review, I commit, I deploy.</li></ol>\n<p>The result is a site that loads faster than 99% of the framework-built web, contains zero unused JavaScript, requires zero dependency updates, and will render identically on the browser of 2035 because it uses nothing but platform APIs.</p>\n<p><strong>This is not nostalgia. This is the frontier.</strong></p>\n<hr>\n<h2 id=\"the-closing-argument\" tabindex=\"0\" data-toc-anchor=\"true\">The closing argument</h2>\n<p>David Poblador ends his descent with this:</p>\n<blockquote>\n<p>\"The frontier of 2026 looks an awful lot like the file you uploaded over FTP.\"</p>\n</blockquote>\n<p>I agree. And I add: the reason you can go back to that file - with better CSS, better JavaScript, better performance, and an AI that writes the boring parts - is that the frameworks finished the work they set out to do. They explored the design space, the platform absorbed the lessons, and the platform is now good enough.</p>\n<p>The frameworks were a scaffold around an unfinished building. The building is finished. Take down the scaffold.</p>\n<p>Vanilla JS plus an LLM is not a regression. It is the final form of an evolution that began when someone typed <code>$(\"#cart\").load(\"/items\")</code> and thought \"there has to be a better way.\" There is now. The better way is the thing you started with, grown up, with an AI at your side.</p>\n<p>Ship HTML. Enhance with vanilla JS. Let the LLM write the glue. The platform handles the rest.</p>\n<p>Welcome back.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-30-vanilla-js-plus-llm-is-the-only-way.png",
      "date_published": "2026-07-30T00:00:00.000Z",
      "tags": [
        "vanilla-js",
        "llm",
        "frontend",
        "architecture",
        "web-standards"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-28-how-to-write-agent-skills-that-actually-trigger.html",
      "url": "https://dout.dev/posts/2026-07-28-how-to-write-agent-skills-that-actually-trigger.html",
      "title": "How to Write Agent Skills That Actually Trigger (The Art of Not Being Ignored)",
      "summary": "The mistake most people make",
      "content_html": "<h2 id=\"the-mistake-most-people-make\" tabindex=\"0\" data-toc-anchor=\"true\">The mistake most people make</h2>\n<p>Most bad skills are not bad because the instructions are weak. They are bad because the skill never loads at the right time, or loads for the wrong task.</p>\n<p>That is the main point I took from Anthropic's PDF, <a href=\"https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">The Complete Guide to Building Skills for Claude</a>, and it is also the point the Medium summary by Ruqaiya Beguwala gets most right. The description field is not marketing copy. It is routing logic written in prose.</p>\n<p>Anthropic's framing is Claude-specific, but the lesson generalizes cleanly to agent skills of any kind. A skill is not \"a prompt you saved somewhere.\" A skill is packaged operational knowledge with an activation rule.</p>\n<h2 id=\"a-skill-is-a-router-a-playbook-and-a-reference-pack\" tabindex=\"0\" data-toc-anchor=\"true\">A skill is a router, a playbook, and a reference pack</h2>\n<p>Anthropic defines a skill as a folder with an exact <code>SKILL.md</code> file plus optional <code>scripts/</code>, <code>references/</code>, and <code>assets/</code>. The useful idea is the three-level loading model:</p>\n<ul>\n<li data-reveal=\"\">YAML frontmatter is always present and helps the model decide whether the skill applies.</li><li data-reveal=\"\">The <code>SKILL.md</code> body is the working playbook.</li><li data-reveal=\"\">Linked files are only loaded when needed.</li></ul>\n<p>That is a better mental model than \"write one giant instruction file.\"</p>\n<p>For agent design, I would translate it like this:</p>\n<ul>\n<li data-reveal=\"\">the frontmatter or metadata is the routing layer;</li><li data-reveal=\"\">the main instructions are the execution layer;</li><li data-reveal=\"\">references and scripts are the depth layer.</li></ul>\n<p>If you collapse all three into one document, the skill gets harder to trigger, harder to maintain, and more expensive to load.</p>\n<h2 id=\"start-with-one-hard-task-not-broad-coverage\" tabindex=\"0\" data-toc-anchor=\"true\">Start with one hard task, not broad coverage</h2>\n<p>One of the better parts of the PDF is that Anthropic pushes two seemingly different ideas at once, and both are correct.</p>\n<p>First, define 2-3 concrete use cases before you start. Second, iterate on one challenging task until the agent can do it reliably, then extract the pattern into a skill.</p>\n<p>That is the right order.</p>\n<p>Do not start with \"project management assistant\" or \"software engineering copilot.\" Those are product categories, not skills. Start with something that has a visible outcome:</p>\n<ul>\n<li data-reveal=\"\">create sprint tasks from current Linear state;</li><li data-reveal=\"\">review a database migration plan for rollout risk;</li><li data-reveal=\"\">turn a design handoff into implementation tasks across Figma, Drive, and Slack;</li><li data-reveal=\"\">generate a weekly research brief in a fixed format.</li></ul>\n<p>If the outcome is narrow, you can tell when the skill works. If the scope is broad, everything looks vaguely plausible and you ship prompt soup.</p>\n<h2 id=\"write-the-trigger-sentence-first\" tabindex=\"0\" data-toc-anchor=\"true\">Write the trigger sentence first</h2>\n<p>The most important field in the whole skill is still the description.</p>\n<p>Anthropic's PDF is very explicit here: the description must say what the skill does and when to use it. It should include concrete trigger phrases users might actually say, stay under 1024 characters, and avoid XML angle brackets because the frontmatter lands in the system prompt.</p>\n<p>The document also adds practical constraints that the summary article only mentions lightly:</p>\n<ul>\n<li data-reveal=\"\"><code>SKILL.md</code> must be named exactly that, case-sensitive;</li><li data-reveal=\"\">the folder should be kebab-case;</li><li data-reveal=\"\"><code>README.md</code> does not belong inside the skill folder;</li><li data-reveal=\"\">names using <code>claude</code> or <code>anthropic</code> are reserved;</li><li data-reveal=\"\">optional fields like <code>compatibility</code>, <code>metadata</code>, and even <code>allowed-tools</code> exist in the reference appendix.</li></ul>\n<p>That level of specificity matters because skills fail for boring reasons more often than clever ones.</p>\n<p>A good description looks more like an API contract than a slogan:</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>---\nname: db-migration-review\ndescription: Reviews database migration plans, flags backward-compatibility and rollout risks, and proposes safe sequencing. Use when the user mentions schema changes, backfills, migrations, rollbacks, or zero-downtime deploys.\nmetadata:\n  version: 1.0.0\n---</code></pre><p>This is specific about the outcome, the scope, and the trigger language. That is what gives the model a chance to route correctly.</p>\n<h2 id=\"instructions-should-be-executable-not-inspirational\" tabindex=\"0\" data-toc-anchor=\"true\">Instructions should be executable, not inspirational</h2>\n<p>Anthropic is also right about the body of the skill: vague verbs are useless.</p>\n<p>\"Validate the data before proceeding\" is not an instruction. \"Run <code>python scripts/validate.py --input {filename}</code> and stop on missing required fields or invalid dates\" is an instruction.</p>\n<p>The more critical the step, the less you should rely on ambiguous language. The PDF says this plainly in the troubleshooting section: for important checks, prefer a bundled validation script because code is deterministic and language interpretation is not.</p>\n<p>That is one of the most transferable ideas in the whole guide.</p>\n<p>A good skill body usually needs four things:</p>\n<ol>\n<li data-reveal=\"\">the ordered steps;</li><li data-reveal=\"\">success conditions for each step;</li><li data-reveal=\"\">examples of common requests;</li><li data-reveal=\"\">troubleshooting for common failures.</li></ol>\n<p>A minimal structure is enough:</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code># Database Migration Review\n\n## Step 1: Read the migration plan\n\nInspect the schema change, expected rollout path, and rollback strategy.\n\n## Step 2: Run deterministic checks\n\nExecute scripts/check_migration.py --plan {filename}\n\n## Step 3: Produce the review\n\nReturn risks, missing safeguards, recommended sequence, and rollback notes.\n\n## Troubleshooting\n\nError: Migration plan missing rollback path\nSolution: Stop and ask the user for rollback semantics before continuing</code></pre><p>This is dull on purpose. Dull is good. Skills are operating manuals, not thought leadership.</p>\n<p>One subtle PDF note is worth keeping: \"take your time\" style performance notes work better in the user prompt than in the skill file. That matches my experience with agent systems generally. Routing and workflow belong in the skill. Session-specific emphasis belongs in the prompt.</p>\n<h2 id=\"pick-a-pattern-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">Pick a pattern on purpose</h2>\n<p>The Anthropic PDF is strongest when it stops talking in abstractions and starts naming workflow patterns. It identifies five that show up repeatedly:</p>\n<ul>\n<li data-reveal=\"\">sequential workflow orchestration;</li><li data-reveal=\"\">multi-MCP coordination;</li><li data-reveal=\"\">iterative refinement;</li><li data-reveal=\"\">context-aware tool selection;</li><li data-reveal=\"\">domain-specific intelligence.</li></ul>\n<p>That list is more useful than it sounds because each pattern implies a different structure.</p>\n<p>A sequential onboarding skill wants strict step ordering and rollback rules. A multi-system handoff skill wants phase boundaries and shared state. An iterative report skill wants explicit quality thresholds and a stop condition. A context-aware storage skill needs a decision tree. A compliance skill needs governance before action.</p>\n<p>If you mix these patterns without choosing one, the skill becomes mushy. It has tools, but no shape.</p>\n<h2 id=\"test-activation-behavior-and-value-separately\" tabindex=\"0\" data-toc-anchor=\"true\">Test activation, behavior, and value separately</h2>\n<p>The testing section in the PDF is better than most AI workflow docs because it separates three different questions.</p>\n<p>First: does the skill trigger when it should? Anthropic suggests 10-20 obvious and paraphrased prompts, plus unrelated prompts that must not trigger. The rough target is 90% on relevant queries and 0% on clearly unrelated ones.</p>\n<p>Second: does the skill actually do the job? Run the same request 3-5 times. Compare structure, tool calls, and failure handling. If results wander too much, the instructions are under-specified.</p>\n<p>Third: is the skill better than not having the skill? Compare tool calls, token use, back-and-forth, and failed API calls. If the skill does not reduce friction or improve consistency, it is decorative.</p>\n<p>The PDF also gives one debugging trick that is almost embarrassingly useful: ask the model, \"When would you use the [skill name] skill?\" The answer reflects the description back at you. If the answer is vague, the routing is vague.</p>\n<p>This is also where the PDF goes beyond the Medium summary in practical detail. It recommends keeping <code>SKILL.md</code> under 5,000 words, moving deep material into <code>references/</code>, and being careful once you have 20-50 skills enabled at once. That is not a theoretical concern. Too many skills turn progressive disclosure back into context sludge.</p>\n<h2 id=\"distribution-matters-more-than-people-think\" tabindex=\"0\" data-toc-anchor=\"true\">Distribution matters more than people think</h2>\n<p>The summary article focuses on the zip-and-upload flow, which is fine for Claude.ai. The PDF goes further and treats distribution as product work.</p>\n<p>The recommended path is: host the skill on GitHub, keep a human-oriented <code>README</code> at the repo level, document why the skill plus your MCP integration are better together, and provide a quick-start guide with examples and screenshots.</p>\n<p>That is the right instinct even outside the Claude ecosystem. If people cannot tell what outcome the skill produces, they will not install it. If the setup guide only talks about folders and YAML, you are describing mechanics instead of value.</p>\n<p>The PDF also makes the API surface explicit. Skills can be managed through <code>/v1/skills</code>, attached through <code>container.skills</code>, and used through the Agent SDK, with the note that the Code Execution Tool beta is required. Even if you never touch Claude's API, the general lesson holds: skills are not just authoring artifacts, they are deployment artifacts.</p>\n<h2 id=\"what-i-would-keep-if-i-were-writing-skills-for-any-agent-stack\" tabindex=\"0\" data-toc-anchor=\"true\">What I would keep if I were writing skills for any agent stack</h2>\n<p>The Anthropic guide is nominally about Claude skills, but most of its best advice is really about agent design in general.</p>\n<p>I would keep five rules:</p>\n<ol>\n<li data-reveal=\"\">Write the routing sentence before the rest of the skill.</li><li data-reveal=\"\">Build around one observable workflow, not a role description.</li><li data-reveal=\"\">Keep the main file short and move depth into references or scripts.</li><li data-reveal=\"\">Put deterministic validation in code whenever failure is expensive.</li><li data-reveal=\"\">Test activation separately from execution and separately from business value.</li></ol>\n<p>The simplest way to say it is this:</p>\n<blockquote>\n<p>A good agent skill is not a prompt dump. It is a narrow workflow with strong routing, explicit steps, and enough structure to fail predictably.</p>\n</blockquote>\n<p>That is the real takeaway from the PDF. The fancy part is not the packaging. The hard part is deciding what the agent should do, when it should do it, and what evidence counts as success.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://generativeai.pub/i-read-anthropics-internal-guide-on-building-claude-skills-here-s-everything-you-need-to-know-b2b8606befb1?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Ruqaiya Beguwala, \"I Read Anthropic's Internal Guide on Building Claude Skills. Here's Everything You Need to Know.\"</a></li><li data-reveal=\"\"><a href=\"https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Anthropic, \"The Complete Guide to Building Skills for Claude\" (PDF)</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-28-how-to-write-agent-skills-that-actually-trigger.png",
      "date_published": "2026-07-28T00:00:00.000Z",
      "tags": [
        "ai",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-25-accessibility-by-design.html",
      "url": "https://dout.dev/posts/2026-07-25-accessibility-by-design.html",
      "title": "Accessibility by Design (Not by Audit, Not by Checklist, by Design)",
      "summary": "We live in an era where technology permeates every aspect of our daily lives. However, not everyone can benefit equally from these advancements. This is where Accessibility by Desi",
      "content_html": "<p>We live in an era where technology permeates every aspect of our daily lives. However, not everyone can benefit equally from these advancements. This is where Accessibility by Design comes into play, a fundamental approach that must be incorporated from the early stages of digital product development. But why is it so important? And what are the advantages it offers in terms of inclusivity, responsiveness, usability, and marketing?</p>\n<h2 id=\"inclusivity-a-social-and-moral-goal\" tabindex=\"0\" data-toc-anchor=\"true\">Inclusivity: A Social and Moral Goal</h2>\n<p>Inclusivity is not just a buzzword; it is a fundamental principle of a fair and just society. About 20% of the world's population lives with some form of disability. Ignoring this significant portion of the population means excluding millions of people from accessing digital resources. Accessibility by Design ensures that our products are usable by everyone, regardless of their physical or cognitive abilities. This not only improves the quality of life for many people but also strengthens our social responsibility as a company.</p>\n<h2 id=\"responsiveness-adapting-to-everyone-s-needs\" tabindex=\"0\" data-toc-anchor=\"true\">Responsiveness: Adapting to Everyone's Needs</h2>\n<p>An accessible design makes digital products more responsive. This means that our software, website, or application can easily adapt to the diverse needs of users. Whether it's people with visual, auditory, motor, or cognitive disabilities, an accessibility-focused approach ensures that everyone can effectively interact with our product. This not only enhances the user experience but also ensures that there are no technological barriers to access.</p>\n<h2 id=\"usability-improving-the-experience-for-everyone\" tabindex=\"0\" data-toc-anchor=\"true\">Usability: Improving the Experience for Everyone</h2>\n<p>Often, accessible design brings significant improvements in overall usability. Features such as clear and readable text, adequate color contrasts, intuitive navigation, and audio/video descriptions not only help users with disabilities but also enhance the experience for everyone. A product that is easy to use is a successful product, capable of meeting the needs of a wide range of users.</p>\n<h2 id=\"marketing-a-competitive-advantage\" tabindex=\"0\" data-toc-anchor=\"true\">Marketing: A Competitive Advantage</h2>\n<p>From a marketing perspective, Accessibility by Design offers extraordinary benefits. With 20% of the world's population consisting of people with disabilities, we are talking about a vast segment of potential users, stakeholders, operators, and customers. Ignoring this segment means missing out on a significant market opportunity. On the other hand, creating accessible products can set our company apart from the competition, positioning us as leaders in the industry and enhancing our reputation.</p>\n<h2 id=\"conclusion\" tabindex=\"0\" data-toc-anchor=\"true\">Conclusion</h2>\n<p>Adopting Accessibility by Design is not just good practice but a winning strategy that encompasses inclusivity, responsiveness, usability, and marketing. It is an investment that pays off handsomely, improving our corporate image, expanding our user base, and demonstrating our commitment to a more equitable and inclusive society.</p>\n<p>Let's make accessibility a priority and work together to create a digital future that is truly for everyone.</p>\n<hr>\n<p>Accessibility by Design is not just a trend but a necessity. We are ready to make a difference. Join us on this journey toward a more inclusive digital world.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-25-accessibility-by-design.png",
      "date_published": "2026-07-25T00:00:00.000Z",
      "tags": [
        "accessibility",
        "design",
        "responsive-design"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-23-manual-a11y-audit.html",
      "url": "https://dout.dev/posts/2026-07-23-manual-a11y-audit.html",
      "title": "Manual A11y Audit: axe, Keyboard, Screen Reader in an Afternoon (You Have Time for This)",
      "summary": "The claim",
      "content_html": "<h2 id=\"the-claim\" tabindex=\"0\" data-toc-anchor=\"true\">The claim</h2>\n<p>An accessibility audit is not a three-week project. For a static blog of reasonable size, a complete manual audit - automated scan plus keyboard-only pass plus screen reader pass - fits in one afternoon. This post is the protocol I use.</p>\n<p>Three passes. Each catches a different class of problem. None replaces the others.</p>\n<h2 id=\"pass-1-axe-in-the-browser\" tabindex=\"0\" data-toc-anchor=\"true\">Pass 1: axe, in the browser</h2>\n<p>Axe-core is the de facto standard for automated a11y checks. It catches mechanical violations - missing labels, contrast failures, heading order skips, ARIA misuse, missing alt text, keyboard traps in known patterns. What it does not catch is semantic correctness and interaction-level issues.</p>\n<p>Time budget: 30 minutes for a small site.</p>\n<p>The easiest way to run axe is the browser extension. Install it, open DevTools, Scan. For deterministic reruns, the axe Chrome extension exports a JSON report; if you want CI integration, use the Playwright or Puppeteer bindings.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { test, expect } from '@playwright/test';\nimport { injectAxe, checkA11y } from '@axe-core/playwright';\n\ntest('home passes axe', async ({ page }) =&gt; {\n  await page.goto('/');\n  await injectAxe(page);\n  const violations = await checkA11y(page, null, {\n    detailedReport: true,\n    axeOptions: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag22aa'] } },\n  });\n  expect(violations).toBeFalsy();\n});</code></pre><p>Common findings on a first-time audit:</p>\n<ul>\n<li data-reveal=\"\">Color contrast failures on secondary text.</li><li data-reveal=\"\">Missing alt text on images that are actually decorative (use <code>alt=\"\"</code>, not no alt).</li><li data-reveal=\"\"><code>aria-label</code> on elements that already have visible accessible text.</li><li data-reveal=\"\">Nested interactive elements (a <code>&lt;button&gt;</code> inside an <code>&lt;a&gt;</code>).</li><li data-reveal=\"\">Empty headings.</li></ul>\n<p>Fix these first. They are mechanical; they do not need judgment. Axe will flag them again if they come back.</p>\n<h2 id=\"pass-2-keyboard-only\" tabindex=\"0\" data-toc-anchor=\"true\">Pass 2: keyboard only</h2>\n<p>Unplug your mouse. Literally, or at least commit to not using it.</p>\n<p>Time budget: 45 minutes.</p>\n<p>The protocol:</p>\n<ol>\n<li data-reveal=\"\"><strong>Tab through the home page.</strong> Every interactive element should receive a visible focus ring. The ring should never be invisible or cut off.</li><li data-reveal=\"\"><strong>Activate each interactive element with Enter or Space.</strong> Buttons work with Space; links with Enter; both should do the right thing.</li><li data-reveal=\"\"><strong>Navigate to a post.</strong> Scroll with arrow keys or Page Down. Tab through the outline. Click an outline link - focus should land inside the section.</li><li data-reveal=\"\"><strong>Open the theme menu (or any modal).</strong> Tab cycles within it. Escape closes it. Focus returns to the trigger.</li><li data-reveal=\"\"><strong>Submit the search form.</strong> Results render. Tab moves into the results list.</li></ol>\n<p>What to look for:</p>\n<ul>\n<li data-reveal=\"\"><strong>Invisible focus.</strong> A focus state that is not visible is a blocker.</li><li data-reveal=\"\"><strong>Focus order that jumps.</strong> Tab should move through the document in reading order. Jumps are usually a sign of <code>order: -1</code> in Flexbox without a matching <code>tabindex</code>.</li><li data-reveal=\"\"><strong>Interactive elements that are not focusable.</strong> A <code>&lt;div onclick&gt;</code> is a keyboard dead zone. Fix by replacing with <code>&lt;button&gt;</code>.</li><li data-reveal=\"\"><strong>Traps without escapes.</strong> Any overlay that does not close with Escape is a bug.</li></ul>\n<p>This pass catches almost everything axe missed. The findings are usually not \"my code is broken;\" they are \"my interaction model assumed a mouse.\"</p>\n<h2 id=\"pass-3-screen-reader\" tabindex=\"0\" data-toc-anchor=\"true\">Pass 3: screen reader</h2>\n<p>Time budget: 90 minutes.</p>\n<p>This is the uncomfortable pass. You have to turn on a screen reader and use the site for real. On macOS, that is VoiceOver. On Windows, NVDA (free) or JAWS (commercial). On iOS, VoiceOver. On Android, TalkBack.</p>\n<p>I use VoiceOver on macOS because it is built in and most of my readers on Apple devices use it. If the site works in VO, it usually works elsewhere, because the assistive APIs are fairly consistent.</p>\n<p>Basic VoiceOver commands:</p>\n<ul>\n<li data-reveal=\"\"><code>Cmd+F5</code> - toggle VoiceOver.</li><li data-reveal=\"\"><code>Ctrl+Option+arrow</code> - move through content.</li><li data-reveal=\"\"><code>Ctrl+Option+U</code> - open the Rotor, which navigates by headings, landmarks, links, etc.</li><li data-reveal=\"\"><code>Ctrl+Option+Space</code> - activate the current element.</li></ul>\n<p>The audit protocol:</p>\n<ol>\n<li data-reveal=\"\"><strong>Land on the home page with VO on.</strong> The page title is announced. The main nav is a landmark. The article list is readable.</li><li data-reveal=\"\"><strong>Open the Rotor with <code>Ctrl+Option+U</code> and navigate by landmarks.</strong> You should hear <code>banner</code>, <code>main</code>, <code>contentinfo</code>. No orphan landmarks. No missing landmarks.</li><li data-reveal=\"\"><strong>Navigate by headings.</strong> The heading tree should be correct - one <code>h1</code>, descending properly. If you hear a skip (\"h2 ... h4\"), that is a violation.</li><li data-reveal=\"\"><strong>Open a post.</strong> The title is read. The date is read (or skipped if decorative). The article content is navigable.</li><li data-reveal=\"\"><strong>Interact with the outline.</strong> Activating a link moves focus, and VO should announce the heading it landed on.</li><li data-reveal=\"\"><strong>Open the theme menu.</strong> VO announces \"dialog\" on open, the first focusable element, and the title.</li></ol>\n<p>The findings from this pass are qualitatively different. They are about announcement correctness and context. Things you might catch:</p>\n<ul>\n<li data-reveal=\"\">A date announced as a number rather than a date, because no <code>&lt;time&gt;</code> element was used.</li><li data-reveal=\"\">An icon button with an <code>aria-label</code> that reads awkwardly.</li><li data-reveal=\"\">A link that says \"Read more\" without context.</li><li data-reveal=\"\">A live region that does not announce because it was not <code>aria-live</code>.</li></ul>\n<p>These are the findings that automated tools cannot produce. They are also the ones that most improve the experience for actual screen reader users.</p>\n<h2 id=\"the-audit-log\" tabindex=\"0\" data-toc-anchor=\"true\">The audit log</h2>\n<p>I keep a simple markdown log per audit. Date, scope, findings, status.</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code># A11y audit - 2026-11-10\n\n## Scope\n\nHome, archive, a random post, search, about.\n\n## Findings\n\n- [x] Home: axe flagged color contrast on `.post-card__date` (3.9:1). Fixed.\n- [x] Post: Tab from outline link did not move focus into the section. Added `tabindex=\"-1\"` on headings. Fixed.\n- [ ] Search: VoiceOver does not announce result count change. `aria-live=\"polite\"` is set but on the wrong element. Open.\n- [x] Archive: focus ring invisible on pagination numbers in dark mode. Bumped outline-offset. Fixed.</code></pre><p>The log is a working document, not a report. I update it as I fix things. At the end of the audit, the open items are the follow-up tasks.</p>\n<h2 id=\"the-rhythm\" tabindex=\"0\" data-toc-anchor=\"true\">The rhythm</h2>\n<p>For dout.dev, this full audit happens every quarter. Short passes (axe only) run on every CI build. The quarterly cadence catches what CI cannot: semantic issues, new content patterns, regressions that axe does not model.</p>\n<h2 id=\"what-i-do-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not do</h2>\n<ul>\n<li data-reveal=\"\"><strong>Hire a professional audit for a personal blog.</strong> Worth it for a product. Overkill for this.</li><li data-reveal=\"\"><strong>Run a full WCAG 2.2 AAA pass.</strong> AAA is not a common benchmark and the incremental work is steep. AA is the target.</li><li data-reveal=\"\"><strong>Cover every browser and screen reader combination.</strong> VO on macOS and NVDA on Windows cover the cases I care about.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Accessibility auditing on a static blog is bounded work with clear protocols. Axe + keyboard + screen reader, in an afternoon, quarterly. The findings get better over time as the codebase gets better; the first audit is always the longest.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://github.com/dequelabs/axe-core?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">axe-core</a></li><li data-reveal=\"\"><a href=\"https://webaim.org/projects/screenreadersurvey/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WebAIM: Screen reader user survey</a></li><li data-reveal=\"\"><a href=\"https://support.apple.com/guide/voiceover/welcome/mac?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">VoiceOver for macOS - Apple docs</a></li><li data-reveal=\"\"><a href=\"https://www.nvaccess.org/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">NVDA screen reader</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/quickref/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.2 Quick Reference</a></li><li data-reveal=\"\"><a href=\"https://www.a11yproject.com/checklist/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">A11y Project checklist</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-23-manual-a11y-audit.png",
      "date_published": "2026-07-23T00:00:00.000Z",
      "tags": [
        "accessibility",
        "tooling"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-21-pnpm-workspaces-for-a-single-site.html",
      "url": "https://dout.dev/posts/2026-07-21-pnpm-workspaces-for-a-single-site.html",
      "title": "pnpm Workspaces for a Single Site: Needed or Not (Spoiler: It Depends)",
      "summary": "The question I keep getting",
      "content_html": "<h2 id=\"the-question-i-keep-getting\" tabindex=\"0\" data-toc-anchor=\"true\">The question I keep getting</h2>\n<p>\"Your repo has <code>pnpm-workspace.yaml</code>. Why? It is one site.\"</p>\n<p>Fair question. A workspace implies multiple packages that share tooling. A single site does not have multiple packages. On the face of it, <code>pnpm-workspace.yaml</code> in a single-site repo is overkill. Sometimes it is. On dout.dev it is not, and this post is the reasoning.</p>\n<h2 id=\"what-a-workspace-actually-costs\" tabindex=\"0\" data-toc-anchor=\"true\">What a workspace actually costs</h2>\n<p>Turning a repo into a pnpm workspace adds:</p>\n<ul>\n<li data-reveal=\"\">One file, <code>pnpm-workspace.yaml</code>, with the packages glob.</li><li data-reveal=\"\">The <code>workspace:</code> protocol for internal package references, if you use it.</li><li data-reveal=\"\">A mental model where <code>node_modules</code> is hoisted at the workspace root, with per-package exceptions.</li></ul>\n<p>That is all. No different commands. No different build time. The incremental cost is almost zero if you stop there.</p>\n<h2 id=\"what-it-lets-you-do-later\" tabindex=\"0\" data-toc-anchor=\"true\">What it lets you do later</h2>\n<p>Three things, each of which I expected to eventually need on dout.dev.</p>\n<p><strong>Extract a package.</strong> If the template engine, the CMS, or the syntax highlighter becomes useful outside this repo, it moves into <code>packages/pix-template-engine/</code> without restructuring anything. The workspace already knows how to build and test sub-packages.</p>\n<p><strong>Share dev tooling across packages.</strong> Biome config, Prettier config, TypeScript config, Playwright config - any of these can live at the root and be inherited by packages. A workspace makes this natural.</p>\n<p><strong>Run scripts across the graph.</strong> <code>pnpm -r test</code>, <code>pnpm -r build</code>, <code>pnpm -r lint</code>. If there are multiple packages, you get parallel execution and topological ordering for free.</p>\n<p>None of these is free-as-in-beer to retrofit. Adding a workspace to an established single-package repo means moving files, updating imports, reshuffling <code>package.json</code> entries, and fixing a week of small breakages. Starting with a workspace costs nothing and avoids that migration.</p>\n<h2 id=\"when-i-would-skip-it\" tabindex=\"0\" data-toc-anchor=\"true\">When I would skip it</h2>\n<p>On a throwaway project that will never be more than one package: skip it. On a learning project where the workspace concept itself is a distraction: skip it. The workspace is a bet on future-you wanting to extract something; if that bet is clearly wrong, do not place it.</p>\n<h2 id=\"the-actual-file\" tabindex=\"0\" data-toc-anchor=\"true\">The actual file</h2>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>packages:\n  - '.'</code></pre><p>That is it. The root itself is the one package in the workspace. Adding more later is a one-line change.</p>\n<p>Compare to the alternative where the repo grows a second package and you have to migrate on the spot. The migration is mechanical, not hard, but every \"mechanical\" task on a side project is a chance to stall. I prefer the inert one-line file.</p>\n<h2 id=\"the-hoisting-detail\" tabindex=\"0\" data-toc-anchor=\"true\">The hoisting detail</h2>\n<p>pnpm workspaces hoist shared dependencies to the root <code>node_modules</code>. That means:</p>\n<ul>\n<li data-reveal=\"\">Packages resolve common dependencies from the root, which reduces duplication and disk usage.</li><li data-reveal=\"\">Per-package versions are respected when they differ.</li><li data-reveal=\"\"><code>node_modules/.bin</code> at the root contains the CLIs from any package.</li></ul>\n<p>The hoisting is usually invisible and always correct. The one case where it is not invisible is when a tool reads <code>node_modules</code> directly and makes assumptions - older tools occasionally get confused. I have not hit this on dout.dev, but it is the reason some projects still prefer yarn 1 or npm with explicit workspaces.</p>\n<h2 id=\"per-package-package-json\" tabindex=\"0\" data-toc-anchor=\"true\">Per-package <code>package.json</code>?</h2>\n<p>Currently there is only the root <code>package.json</code>. If I extracted the template engine into <code>packages/pix-template-engine/</code>, it would have its own <code>package.json</code> with <code>name</code>, <code>version</code>, <code>exports</code>, and dependencies declared there. The main repo would reference it as <code>\"pix-template-engine\": \"workspace:*\"</code>.</p>\n<p>The <code>workspace:*</code> protocol means \"use the current version in the workspace, whatever that is.\" It is the feature that makes local development across packages painless - you do not <code>npm link</code>, you do not publish to a test registry. You just work across the tree.</p>\n<h2 id=\"monorepo-vs-workspace\" tabindex=\"0\" data-toc-anchor=\"true\">Monorepo vs workspace</h2>\n<p>These terms get conflated. A monorepo is a repository that contains multiple projects. A workspace is a package-manager feature that supports monorepos. You can have a monorepo without workspaces (you can have a monorepo without any package manager conventions at all, like the Linux kernel). You cannot really have workspaces without a monorepo - it would be pointless.</p>\n<p>For dout.dev, I currently have a monorepo-of-one, with workspace tooling ready for the day it becomes a monorepo-of-several.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A pnpm workspace on a single-site repo is cheap insurance. It costs nothing today and avoids a migration tomorrow. On a throwaway project, skip it. On a project you expect to live for years, the one-line <code>pnpm-workspace.yaml</code> is worth adding upfront.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://pnpm.io/workspaces?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">pnpm workspaces</a></li><li data-reveal=\"\"><a href=\"https://pnpm.io/workspaces?from=dout.dev#workspace-protocol-workspace\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">The <code>workspace:</code> protocol</a></li><li data-reveal=\"\"><a href=\"https://monorepo.tools/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Monorepo tooling comparison - monorepo.tools</a></li><li data-reveal=\"\"><a href=\"https://nx.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Nx</a> - if the workspace grows beyond pnpm-native capabilities</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-21-pnpm-workspaces-for-a-single-site.png",
      "date_published": "2026-07-21T00:00:00.000Z",
      "tags": [
        "tooling",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-18-core-web-vitals-in-production.html",
      "url": "https://dout.dev/posts/2026-07-18-core-web-vitals-in-production.html",
      "title": "Core Web Vitals in Production: LCP, CLS, INP on a Static Blog (Real Numbers, No Bullshit)",
      "summary": "The three numbers that matter",
      "content_html": "<h2 id=\"the-three-numbers-that-matter\" tabindex=\"0\" data-toc-anchor=\"true\">The three numbers that matter</h2>\n<p>Core Web Vitals are not a complete performance model. They are three numbers Google decided matter for user experience, and they correlate strongly with perceived quality. For a content site, the three are:</p>\n<ul>\n<li data-reveal=\"\"><strong>LCP (Largest Contentful Paint)</strong> - how long until the main element of the page appears. Target: under 2.5s.</li><li data-reveal=\"\"><strong>CLS (Cumulative Layout Shift)</strong> - how much the layout jumps during load. Target: under 0.1.</li><li data-reveal=\"\"><strong>INP (Interaction to Next Paint)</strong> - how responsive the page feels on click or keystroke. Target: under 200ms.</li></ul>\n<p>On dout.dev the live numbers are well under target on desktop and comfortably under on mobile. This post is what made that achievable, with the specific choices that matter.</p>\n<h2 id=\"lcp-the-image-the-font-and-the-handful-of-milliseconds-that-add-up\" tabindex=\"0\" data-toc-anchor=\"true\">LCP: the image, the font, and the handful of milliseconds that add up</h2>\n<p>For a blog, the LCP element is almost always the post cover image or the first heading. Two decisions dominate.</p>\n<p><strong>The cover image is eager and high-priority.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img\n  src=\"/assets/images/cover.jpg\"\n  alt=\"...\"\n  width=\"1200\"\n  height=\"630\"\n  loading=\"eager\"\n  fetchpriority=\"high\"\n  decoding=\"async\"\n/&gt;</code></pre><p>Default <code>loading=\"lazy\"</code> on the LCP image delays the one number that most affects the score. <code>fetchpriority=\"high\"</code> moves the image up in the network queue. Both are opt-in changes from the default behavior, and both are measurable.</p>\n<p><strong>The font that renders the LCP text is preloaded.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"preload\" as=\"font\" type=\"font/woff2\" href=\"/assets/fonts/Inter-Bold.woff2\" crossorigin /&gt;</code></pre><p>Without preload, the font is discovered when the CSS parses the <code>@font-face</code> rule, which is at least one round trip later. Preloading the weight used in the LCP heading saves a round trip and prevents the \"invisible text\" flash while the font arrives.</p>\n<p><strong>No render-blocking third-party resources above the fold.</strong> No Google Fonts link, no analytics script in the head, no \"quick chat widget\" that loads before the page. Every third-party script on dout.dev loads after the main content, or not at all on the critical path.</p>\n<h2 id=\"cls-known-dimensions-everywhere\" tabindex=\"0\" data-toc-anchor=\"true\">CLS: known dimensions, everywhere</h2>\n<p>Layout shift is mostly a function of elements arriving after layout is computed. Four rules cover 95% of it.</p>\n<p><strong>Every image has <code>width</code> and <code>height</code>.</strong> The post generator reads the image manifest and emits the attributes. The browser reserves the box at the correct aspect ratio before the pixels arrive.</p>\n<p><strong>Web fonts do not shift layout.</strong> <code>font-display: swap</code> uses a fallback while the web font loads, and the fallback is chosen to have similar metrics. The metric pair I use for Inter is <code>Arial</code> with <code>size-adjust</code> and <code>ascent-override</code> tuned:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>@font-face {\n  font-family: 'Inter Fallback';\n  src: local('Arial');\n  size-adjust: 107%;\n  ascent-override: 90%;\n}\n\nbody {\n  font-family: 'Inter', 'Inter Fallback', sans-serif;\n}</code></pre><p>When Inter loads, the text does not visibly shift because the fallback is already sized to match. This is a handful of CSS lines that prevent an entire class of \"text jumped when font arrived\" bugs.</p>\n<p><strong>Embedded iframes have reserved space.</strong> The Giscus comments shell has a minimum height before the iframe loads. When the iframe arrives and reports its real height, the shell expands downward, which does not affect layout above it.</p>\n<p><strong>Dynamically injected content pushes nothing.</strong> The one place that loads content dynamically on dout.dev is the comments. Because they are at the bottom of the page, they cannot shift layout above them.</p>\n<h2 id=\"inp-small-main-thread-no-blocking-handlers\" tabindex=\"0\" data-toc-anchor=\"true\">INP: small main thread, no blocking handlers</h2>\n<p>INP replaced FID as the responsiveness metric because FID only measured the first interaction. INP measures every interaction and reports the 75th percentile.</p>\n<p>Three decisions keep it low.</p>\n<p><strong>The main thread is small.</strong> Total JavaScript on a post page is under 10KB gzipped. No framework runtime, no hydration, no dev-mode analytics agent. The JS that runs is the theme switcher, the scrollspy, the lazy-load observer, and the search initializer. Each is small and non-blocking.</p>\n<p><strong>Event handlers are short.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>document.addEventListener('click', (event) =&gt; {\n  const toggle = event.target.closest('.theme-toggle');\n  if (toggle) handleThemeToggle();\n});</code></pre><p>A delegated click handler is cheaper than 20 individual ones. The work inside each handler is under 5ms on a mid-tier phone.</p>\n<p><strong>No synchronous layout thrashing.</strong> When the color-scheme changes, I write to <code>document.documentElement.dataset.colorScheme</code>, which sets an attribute. The browser recomputes styles once on the next animation frame. No forced layout, no <code>offsetWidth</code> reads in a loop.</p>\n<h2 id=\"measuring-it-in-production\" tabindex=\"0\" data-toc-anchor=\"true\">Measuring it in production</h2>\n<p>Lab measurements (Lighthouse) are useful for finding regressions. Field measurements (real users) are the ones that actually count for SEO. For a small site without a backend, the options are:</p>\n<ul>\n<li data-reveal=\"\"><strong>Chrome User Experience Report (CrUX)</strong> - Google publishes aggregate field data for origins with enough traffic. Check your site on PageSpeed Insights; if \"Real user experience\" appears, CrUX has data.</li><li data-reveal=\"\"><strong>Web Vitals JS library</strong> - ship a small script that posts LCP, CLS, INP to your analytics. On dout.dev the analytics endpoint is a simple POST that records page hits and metrics without cookies.</li></ul>\n<p>The library is under 3KB gzipped and the handler is short:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { onCLS, onLCP, onINP } from 'web-vitals';\n\nfunction send(metric) {\n  navigator.sendBeacon('/metrics', JSON.stringify(metric));\n}\n\nonLCP(send);\nonCLS(send);\nonINP(send);</code></pre><p>Collecting field vitals for a small blog is optional. Collecting them is how you catch regressions that lab tests miss - device variability, network variability, the long tail of \"users on 3G in a basement.\"</p>\n<h2 id=\"the-cost-i-did-not-pay\" tabindex=\"0\" data-toc-anchor=\"true\">The cost I did not pay</h2>\n<ul>\n<li data-reveal=\"\"><strong>No SSR framework.</strong> A static site is already pre-rendered. Adding SSR on top is a performance anti-pattern for content sites.</li><li data-reveal=\"\"><strong>No runtime optimization service.</strong> No image CDN resizing on the fly, no edge function transforming HTML. The file you serve is the file you built.</li><li data-reveal=\"\"><strong>No aggressive code splitting.</strong> The JS is small enough that splitting adds overhead without meaningful benefit.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Core Web Vitals on a static blog are a solved problem if you make the right small choices: eager LCP image, font preload, no third-party critical-path scripts, known image dimensions, font metric matching, a small main thread. Each is a specific, bounded decision. None requires a framework.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://web.dev/articles/vitals?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Core Web Vitals - web.dev</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/optimize-lcp?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize LCP</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/optimize-cls?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize CLS</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/optimize-inp?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize INP</a></li><li data-reveal=\"\"><a href=\"https://github.com/GoogleChrome/web-vitals?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>web-vitals</code> JavaScript library</a></li><li data-reveal=\"\"><a href=\"https://developer.chrome.com/docs/crux?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Chrome User Experience Report</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/font-fallbacks?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Font metric matching - web.dev</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-18-core-web-vitals-in-production.png",
      "date_published": "2026-07-18T00:00:00.000Z",
      "tags": [
        "performance",
        "seo",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-14-honest-focus-traps-escape.html",
      "url": "https://dout.dev/posts/2026-07-14-honest-focus-traps-escape.html",
      "title": "Honest Focus Traps and Escape: Keyboard-First Menus (Without Locking Users In)",
      "summary": "The misconception",
      "content_html": "<h2 id=\"the-misconception\" tabindex=\"0\" data-toc-anchor=\"true\">The misconception</h2>\n<p>\"Focus trap\" has a bad reputation because most implementations get it wrong. A trap that is actually a prison - where the user cannot escape with Escape, cannot close with a click outside, and cannot tell that they are trapped - is a bug.</p>\n<p>A focus trap done right is a kindness. It says: \"while this overlay is open, Tab cycles within it. When you close it, focus returns where it came from. Escape works. Outside click works.\"</p>\n<p>On dout.dev the mobile menu, the search dialog, and any future modal follow the same rules. This post is those rules, with code.</p>\n<h2 id=\"when-a-focus-trap-is-appropriate\" tabindex=\"0\" data-toc-anchor=\"true\">When a focus trap is appropriate</h2>\n<p>Two criteria, both required.</p>\n<ol>\n<li data-reveal=\"\"><strong>The overlay is modal.</strong> The user is expected to interact with it or dismiss it before doing anything else. A non-modal popover - a tooltip, an inline disclosure - should not trap focus.</li><li data-reveal=\"\"><strong>The overlay visually blocks the rest of the page.</strong> A full-screen dialog, a slide-in panel, a menu that darkens the page behind it.</li></ol>\n<p>If either is false, do not add a trap. A tooltip trap is a bug.</p>\n<h2 id=\"the-five-rules-of-a-good-trap\" tabindex=\"0\" data-toc-anchor=\"true\">The five rules of a good trap</h2>\n<p><strong>Rule 1: remember where focus was.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>let previouslyFocused = null;\n\nfunction openOverlay(overlay) {\n  previouslyFocused = document.activeElement;\n  // ...\n}\n\nfunction closeOverlay(overlay) {\n  overlay.hidden = true;\n  previouslyFocused?.focus();\n  previouslyFocused = null;\n}</code></pre><p>When the overlay closes, focus returns to the element that opened it. Without this, focus goes to <code>document.body</code>, and the next Tab jumps to the top of the page. Disorienting.</p>\n<p><strong>Rule 2: move focus into the overlay on open.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function openOverlay(overlay) {\n  previouslyFocused = document.activeElement;\n  overlay.hidden = false;\n\n  const firstFocusable = overlay.querySelector(\n    'a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\"-1\"])'\n  );\n  firstFocusable?.focus();\n}</code></pre><p>The first interactive element inside the overlay receives focus. Screen readers announce it. Keyboard users are immediately in context.</p>\n<p><strong>Rule 3: cycle Tab within the overlay.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function trapFocus(event, overlay) {\n  if (event.key !== 'Tab') return;\n\n  const focusables = overlay.querySelectorAll(\n    'a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\"-1\"])'\n  );\n  if (focusables.length === 0) return;\n\n  const first = focusables[0];\n  const last = focusables[focusables.length - 1];\n\n  if (event.shiftKey &amp;&amp; document.activeElement === first) {\n    event.preventDefault();\n    last.focus();\n  } else if (!event.shiftKey &amp;&amp; document.activeElement === last) {\n    event.preventDefault();\n    first.focus();\n  }\n}</code></pre><p>Tabbing past the last element wraps to the first. Shift+Tab past the first wraps to the last. Anywhere in between, default Tab behavior.</p>\n<p><strong>Rule 4: Escape closes.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function handleKeydown(event, overlay) {\n  if (event.key === 'Escape') {\n    event.preventDefault();\n    closeOverlay(overlay);\n    return;\n  }\n  trapFocus(event, overlay);\n}</code></pre><p>Universal expectation. Not optional.</p>\n<p><strong>Rule 5: outside click closes.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>overlay.addEventListener('click', (event) =&gt; {\n  if (event.target === overlay) {\n    // Click on the backdrop, not a child\n    closeOverlay(overlay);\n  }\n});</code></pre><p>If the overlay is a full-screen layer with a visible backdrop, clicking the backdrop is a valid close affordance. Screen reader users have Escape; pointer users have click-outside.</p>\n<h2 id=\"putting-it-together\" tabindex=\"0\" data-toc-anchor=\"true\">Putting it together</h2>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>class Overlay {\n  constructor(root, { onClose } = {}) {\n    this.root = root;\n    this.onClose = onClose;\n    this.previouslyFocused = null;\n    this.handleKeydown = this.handleKeydown.bind(this);\n    this.handleClick = this.handleClick.bind(this);\n  }\n\n  open() {\n    this.previouslyFocused = document.activeElement;\n    this.root.hidden = false;\n    const first = this.getFocusables()[0];\n    first?.focus();\n    document.addEventListener('keydown', this.handleKeydown);\n    this.root.addEventListener('click', this.handleClick);\n  }\n\n  close() {\n    this.root.hidden = true;\n    document.removeEventListener('keydown', this.handleKeydown);\n    this.root.removeEventListener('click', this.handleClick);\n    this.previouslyFocused?.focus();\n    this.previouslyFocused = null;\n    this.onClose?.();\n  }\n\n  handleKeydown(event) {\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      this.close();\n      return;\n    }\n    if (event.key !== 'Tab') return;\n\n    const focusables = this.getFocusables();\n    if (focusables.length === 0) return;\n\n    const first = focusables[0];\n    const last = focusables[focusables.length - 1];\n\n    if (event.shiftKey &amp;&amp; document.activeElement === first) {\n      event.preventDefault();\n      last.focus();\n    } else if (!event.shiftKey &amp;&amp; document.activeElement === last) {\n      event.preventDefault();\n      first.focus();\n    }\n  }\n\n  handleClick(event) {\n    if (event.target === this.root) this.close();\n  }\n\n  getFocusables() {\n    return this.root.querySelectorAll('a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\"-1\"])');\n  }\n}</code></pre><p>60 lines, reusable, no framework. Every overlay on dout.dev uses it.</p>\n<h2 id=\"the-aria-that-the-component-needs\" tabindex=\"0\" data-toc-anchor=\"true\">The ARIA that the component needs</h2>\n<p>The overlay element itself needs the right roles and attributes, not just the JS.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;div class=\"overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"overlay-title\" hidden&gt;\n  &lt;h2 id=\"overlay-title\"&gt;Menu&lt;/h2&gt;\n  &lt;!-- ... --&gt;\n&lt;/div&gt;</code></pre><ul>\n<li data-reveal=\"\"><code>role=\"dialog\"</code> tells assistive tech this is a modal.</li><li data-reveal=\"\"><code>aria-modal=\"true\"</code> indicates that the rest of the page is inert while the dialog is open.</li><li data-reveal=\"\"><code>aria-labelledby</code> points to the visible title so screen readers announce it when focus moves in.</li><li data-reveal=\"\"><code>hidden</code> is the default state; JS toggles it.</li></ul>\n<p>Without these, the JS trap works for keyboard users but screen readers do not understand that they are in a modal context. Both layers have to agree.</p>\n<h2 id=\"what-i-do-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not do</h2>\n<ul>\n<li data-reveal=\"\"><strong>Focus-within timing hacks.</strong> <code>setTimeout(focus, 0)</code> is almost always a sign that the DOM is not ready. Fix the timing, not the symptom.</li><li data-reveal=\"\"><strong>Overscroll lock via <code>body { overflow: hidden }</code>.</strong> Sometimes useful, often jarring. On dout.dev the overlays are full-screen so scroll lock is redundant.</li><li data-reveal=\"\"><strong>Inert polyfills.</strong> The <code>inert</code> attribute on the background content is still not uniformly supported. <code>aria-modal=\"true\"</code> is enough for current assistive tech.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A focus trap is not the JavaScript pattern. It is the contract: remember where focus was, move focus to the overlay, cycle Tab within it, honor Escape, honor outside click. Sixty lines of code, five rules, one reusable class. Every modal on a well-built site follows the same shape.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WAI-ARIA Authoring Practices: Dialog (Modal) Pattern</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Inert - MDN</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.1.1 Keyboard</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.4.3 Focus Order</a></li><li data-reveal=\"\"><a href=\"https://github.com/focus-trap/focus-trap?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">focus-trap (library)</a> - if you prefer a ready-made option</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-14-honest-focus-traps-escape.png",
      "date_published": "2026-07-14T00:00:00.000Z",
      "tags": [
        "accessibility",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-11-strict-csp-self-hosted-fonts.html",
      "url": "https://dout.dev/posts/2026-07-11-strict-csp-self-hosted-fonts.html",
      "title": "Strict CSP + Self-Hosted Fonts: unsafe-inline Is a Bad Friend (And I Will Die on This Hill)",
      "summary": "The claim",
      "content_html": "<h2 id=\"the-claim\" tabindex=\"0\" data-toc-anchor=\"true\">The claim</h2>\n<p>Most sites ship a Content Security Policy that is effectively \"please stop yelling at me.\" A policy full of <code>unsafe-inline</code>, <code>*</code>, and wildcard sources is better than no policy, but not by much. A strict CSP is achievable on a modern static blog, it stops a large class of attacks dead, and it forces a few discipline improvements that pay off in other places.</p>\n<p>On dout.dev the policy is strict enough to block an inline <code>&lt;script&gt;</code> I might write by accident. This post is the policy, the font story that enabled it, and the three gotchas I hit getting there.</p>\n<h2 id=\"the-current-policy\" tabindex=\"0\" data-toc-anchor=\"true\">The current policy</h2>\n<p>Emitted via HTTP headers in production. Also mirrored as a <code>&lt;meta&gt;</code> tag for local preview (with one adjustment - <code>upgrade-insecure-requests</code> belongs in headers, not meta).</p>\n<pre is=\"pix-highlighter\"><code>Content-Security-Policy:\n  default-src 'self';\n  base-uri 'self';\n  form-action 'self';\n  object-src 'none';\n  img-src 'self' data: https:;\n  font-src 'self';\n  media-src 'self';\n  manifest-src 'self';\n  worker-src 'self';\n  script-src 'self' 'unsafe-inline' https://giscus.app;\n  style-src 'self' 'unsafe-inline';\n  connect-src 'self' https://giscus.app;\n  frame-src https://giscus.app https://codepen.io;\n  upgrade-insecure-requests</code></pre><p>Tight, but pragmatic. Three concessions and the reasons for each.</p>\n<p><strong><code>'unsafe-inline'</code> in <code>script-src</code>.</strong> Two inline scripts survive in the head: the theme pre-paint resolver and the file-preview banner. Neither accepts user input. Both could be moved to nonce-based CSP, but for a blog with a single deployment origin the cost of a nonce (rewriting the build to inject a fresh nonce per response) exceeds the benefit.</p>\n<p><strong><code>'unsafe-inline'</code> in <code>style-src</code>.</strong> A few critical CSS declarations are inlined for performance (the file-preview banner, the theme bootstrap). Same reasoning. For a team product I would invest in a nonce; for a blog I did not.</p>\n<p><strong><code>https://giscus.app</code> allowed on <code>script-src</code>, <code>connect-src</code>, <code>frame-src</code>.</strong> Giscus is a third-party comment system. It runs a script in the page, connects back to its own domain, and embeds an iframe. All three are required for the feature to work.</p>\n<p>Everything else is <code>'self'</code> or <code>'none'</code>. No CDNs, no third-party analytics, no embed farms.</p>\n<h2 id=\"the-font-story-that-made-font-src-self-possible\" tabindex=\"0\" data-toc-anchor=\"true\">The font story that made <code>font-src 'self'</code> possible</h2>\n<p>A common CSP mistake is leaving <code>font-src</code> wide-open because \"we use Google Fonts.\" The fix is to stop using Google Fonts as a third-party dependency and serve the fonts from your own origin.</p>\n<p>The practical benefit is not just security. It is performance: one fewer DNS lookup, one fewer TLS handshake, one fewer cross-origin preconnect. For the LCP on a content-heavy page, that is measurable.</p>\n<p>The mechanics:</p>\n<ol>\n<li data-reveal=\"\">Download the font files (WOFF2 and optionally WOFF) from the source (Google Fonts has a download button; Inter has a direct distribution; every good font does).</li><li data-reveal=\"\">Place them in <code>src/assets/fonts/</code>.</li><li data-reveal=\"\">Declare <code>@font-face</code> rules pointing at the local files.</li><li data-reveal=\"\">Preload the critical weight so the browser starts the font download early.</li></ol>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>@font-face {\n  font-family: 'Inter';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url('/assets/fonts/Inter-Regular.woff2') format('woff2');\n}\n\n@font-face {\n  font-family: 'Inter';\n  font-style: normal;\n  font-weight: 700;\n  font-display: swap;\n  src: url('/assets/fonts/Inter-Bold.woff2') format('woff2');\n}</code></pre><pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"preload\" as=\"font\" type=\"font/woff2\" href=\"/assets/fonts/Inter-Regular.woff2\" crossorigin /&gt;</code></pre><p><code>font-display: swap</code> tells the browser to use the fallback font until the custom one loads. It prevents the \"invisible text flash\" that <code>font-display: block</code> can cause.</p>\n<p>The <code>crossorigin</code> on the preload is required even for same-origin fonts. Without it, the browser downloads the font twice - once for the preload, once for the actual request - because the cache key differs. One attribute, a real performance win.</p>\n<h2 id=\"the-three-gotchas\" tabindex=\"0\" data-toc-anchor=\"true\">The three gotchas</h2>\n<p>The strict policy broke three things before it worked. Each is worth calling out.</p>\n<h3 id=\"1-inline-event-handlers-are-not-allowed\" tabindex=\"0\" data-toc-anchor=\"true\">1. Inline event handlers are not allowed</h3>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Breaks under strict CSP --&gt;\n&lt;button onclick=\"doThing()\"&gt;Click&lt;/button&gt;</code></pre><p>Every inline <code>on*</code> handler in the codebase got refactored into <code>addEventListener</code> calls in a shared script. Not hard, but easy to miss - and the browser console tells you exactly where the CSP violated, so it is a mechanical cleanup.</p>\n<h3 id=\"2-data-images-are-commonly-blocked\" tabindex=\"0\" data-toc-anchor=\"true\">2. <code>data:</code> images are commonly blocked</h3>\n<pre is=\"pix-highlighter\"><code>img-src 'self' data: https:;</code></pre><p>The <code>data:</code> source is explicitly listed because inline SVGs and base64-encoded small images (favicons, icons) use <code>data:</code> URIs. Without that, the placeholders break.</p>\n<p><code>https:</code> is included because external cover images reference HTTPS URLs. Tight enough to keep out <code>http:</code> and <code>file:</code>; permissive enough to not break the CMS when a post links an external image.</p>\n<h3 id=\"3-the-service-worker-needs-worker-src-self\" tabindex=\"0\" data-toc-anchor=\"true\">3. The service worker needs <code>worker-src 'self'</code></h3>\n<p>Omitted from the policy, the service worker fails to register and there is nothing in the console that clearly says why. <code>worker-src 'self'</code> makes it work.</p>\n<h2 id=\"csp-violation-reporting-for-when-it-breaks-later\" tabindex=\"0\" data-toc-anchor=\"true\">CSP violation reporting, for when it breaks later</h2>\n<p>The policy will break something in the future. A new feature, a new library, a copy-pasted embed. The way to find out is to wire up a CSP report endpoint.</p>\n<pre is=\"pix-highlighter\"><code>Content-Security-Policy:\n  ...;\n  report-to csp-endpoint;\n  report-uri /csp-report;</code></pre><p>On a static site there is no endpoint to receive the reports. I use <code>report-uri.com</code> or a simple edge function to receive violations. The first time something breaks in production, the report arrives before the complaint does.</p>\n<h2 id=\"what-a-strict-csp-actually-stops\" tabindex=\"0\" data-toc-anchor=\"true\">What a strict CSP actually stops</h2>\n<ul>\n<li data-reveal=\"\"><strong>Cross-site script injection</strong> from comments or reflected input. With <code>'self'</code> on scripts, an injected <code>&lt;script src=\"evil.com/x.js\"&gt;</code> does not execute.</li><li data-reveal=\"\"><strong>Clickjacking</strong> via framing. <code>frame-ancestors 'none'</code> (not in the policy above, worth adding) plus the appropriate <code>X-Frame-Options</code> header stops the page from being embedded.</li><li data-reveal=\"\"><strong>Form-hijacking.</strong> <code>form-action 'self'</code> means a forged form cannot POST to an attacker's domain.</li><li data-reveal=\"\"><strong>Accidental third-party pixel tracking.</strong> If a future me copy-pastes a tracking snippet, it is blocked at the CSP layer before it phones home.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A strict CSP on a modern static site is achievable and not exotic. The enabling moves are serving fonts from the same origin, removing inline event handlers, and being honest about the third-party embeds you need. Everything else is <code>'self'</code> or <code>'none'</code>.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://content-security-policy.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Content Security Policy Reference - content-security-policy.com</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSP on MDN</a></li><li data-reveal=\"\"><a href=\"https://css-tricks.com/snippets/css/using-font-face-in-css/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Self-hosting Google Fonts - CSS-Tricks</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>font-display</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://csp-evaluator.withgoogle.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSP Evaluator - Google</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-11-strict-csp-self-hosted-fonts.png",
      "date_published": "2026-07-11T00:00:00.000Z",
      "tags": [
        "security",
        "performance",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-07-dark-mode-accent-prefers-color-scheme.html",
      "url": "https://dout.dev/posts/2026-07-07-dark-mode-accent-prefers-color-scheme.html",
      "title": "Dark Mode, Accent Color, `prefers-color-scheme`: the Theme Switcher From Scratch",
      "summary": "The feature, and the three traps",
      "content_html": "<h2 id=\"the-feature-and-the-three-traps\" tabindex=\"0\" data-toc-anchor=\"true\">The feature, and the three traps</h2>\n<p>A theme switcher looks simple. Light mode, dark mode, auto. Maybe an accent color. In practice it has three common bugs I see in almost every implementation.</p>\n<ol>\n<li data-reveal=\"\"><strong>Flash of wrong theme.</strong> The page loads in light, the script runs, the theme swaps to dark. For a fraction of a second the user sees the wrong colors.</li><li data-reveal=\"\"><strong>System preference ignored after a manual override.</strong> The user picks \"dark,\" closes the tab, comes back later. The site is still dark even though the user has since switched their OS to light. That is correct. The inverse bug: user picked \"auto,\" OS switches to dark, the site stays light because the script does not listen for the change.</li><li data-reveal=\"\"><strong>Preference lost on refresh.</strong> The theme was in memory, not persisted.</li></ol>\n<p>The switcher on dout.dev addresses each explicitly. The code is under 50 lines.</p>\n<h2 id=\"the-token-architecture-it-relies-on\" tabindex=\"0\" data-toc-anchor=\"true\">The token architecture it relies on</h2>\n<p>The switcher only works because the CSS is already token-driven. Every component reads semantic tokens; semantic tokens are defined on <code>:root</code> and overridden per theme.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --color-bg: var(--surface-1);\n  --color-fg: var(--text-primary);\n  --color-accent: #ff6b3d;\n}\n\n[data-color-scheme='light'] {\n  --color-bg: #fafafa;\n  --color-fg: #1a1a1a;\n}\n\n[data-color-scheme='dark'] {\n  --color-bg: #0b0b0f;\n  --color-fg: #e7e7ef;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root:not([data-color-scheme]) {\n    --color-bg: #0b0b0f;\n    --color-fg: #e7e7ef;\n  }\n}</code></pre><p>The rules:</p>\n<ul>\n<li data-reveal=\"\"><code>:root</code> defines the defaults (the \"auto\" case without system preference).</li><li data-reveal=\"\"><code>[data-color-scheme='light']</code> and <code>[data-color-scheme='dark']</code> override for explicit choices.</li><li data-reveal=\"\">The <code>@media (prefers-color-scheme: dark)</code> block kicks in only when no explicit <code>data-color-scheme</code> is set - the <code>:not([data-color-scheme])</code> selector - so user choice always wins over system preference.</li></ul>\n<h2 id=\"no-flash-of-wrong-theme\" tabindex=\"0\" data-toc-anchor=\"true\">No flash of wrong theme</h2>\n<p>The browser must know the theme before it paints the first frame. That means the theme resolution happens in an inline script in the <code>&lt;head&gt;</code>, before any CSS that depends on it.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;script&gt;\n  (function () {\n    try {\n      var stored = localStorage.getItem('color-scheme');\n      var stored_accent = localStorage.getItem('accent');\n      if (stored === 'light' || stored === 'dark') {\n        document.documentElement.dataset.colorScheme = stored;\n      }\n      if (stored_accent) {\n        document.body.dataset.accent = stored_accent;\n      }\n    } catch (_) {\n      /* localStorage unavailable */\n    }\n  })();\n&lt;/script&gt;</code></pre><p>This script is blocking and synchronous and that is exactly what you want. It runs before the body parses, sets the attribute, and the initial CSS cascade applies the correct theme before paint.</p>\n<p>If the user has never made a choice, no attribute is set, and the <code>prefers-color-scheme</code> media query rules. That is the \"auto\" path.</p>\n<h2 id=\"the-toggle-button\" tabindex=\"0\" data-toc-anchor=\"true\">The toggle button</h2>\n<p>The toggle is a button that cycles Auto → Dark → Light → Auto. The current state is mirrored to <code>aria-pressed</code> or announced via text for screen readers.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;button class=\"theme-toggle\" aria-label=\"Change theme\"&gt;\n  &lt;span class=\"theme-toggle__label\"&gt;Auto&lt;/span&gt;\n&lt;/button&gt;</code></pre><pre is=\"pix-highlighter\" data-lang=\"js\"><code>const toggle = document.querySelector('.theme-toggle');\nconst label = toggle.querySelector('.theme-toggle__label');\nconst order = [null, 'dark', 'light'];\n\nfunction setTheme(value) {\n  if (value === null) {\n    delete document.documentElement.dataset.colorScheme;\n    localStorage.removeItem('color-scheme');\n    label.textContent = 'Auto';\n  } else {\n    document.documentElement.dataset.colorScheme = value;\n    localStorage.setItem('color-scheme', value);\n    label.textContent = value === 'dark' ? 'Dark' : 'Light';\n  }\n}\n\ntoggle.addEventListener('click', () =&gt; {\n  const current = document.documentElement.dataset.colorScheme ?? null;\n  const next = order[(order.indexOf(current) + 1) % order.length];\n  setTheme(next);\n});\n\nconst currentOnLoad = document.documentElement.dataset.colorScheme ?? null;\nlabel.textContent = currentOnLoad === null ? 'Auto' : currentOnLoad === 'dark' ? 'Dark' : 'Light';</code></pre><p>The initial <code>label.textContent</code> assignment is subtle: on page load, the inline script has already applied the theme, so we read back from the DOM to render the correct label.</p>\n<h2 id=\"listening-for-system-preference-changes\" tabindex=\"0\" data-toc-anchor=\"true\">Listening for system preference changes</h2>\n<p>In \"auto\" mode, when the OS switches theme, the site should update live. The <code>matchMedia</code> API emits change events:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const media = matchMedia('(prefers-color-scheme: dark)');\nmedia.addEventListener('change', () =&gt; {\n  // The CSS handles the color change automatically via the media query.\n  // Nothing for us to do - the media query in CSS observes the same source.\n});</code></pre><p>Actually, nothing for us to do. The CSS already responds to the system change; this event is only useful if you want to mirror the state somewhere in the UI. If the toggle's label shows \"Auto,\" the visible state on screen updates naturally through CSS.</p>\n<h2 id=\"accent-color-picker\" tabindex=\"0\" data-toc-anchor=\"true\">Accent color picker</h2>\n<p>Accent is orthogonal to light/dark. A user can pick a dark theme with a green accent or a light theme with a violet accent. The primary accent is a separate attribute on <code>body</code>.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>body[data-accent='violet'] {\n  --color-accent: #8b5cf6;\n}\nbody[data-accent='green'] {\n  --color-accent: #22c55e;\n}</code></pre><p>The picker is a radio group or a row of swatches. Selection writes to <code>body[data-accent]</code> and persists to <code>localStorage</code>.</p>\n<p>The one detail that makes this land: the CSS <code>--color-accent</code> is referenced by derived tokens (like <code>--color-accent-soft</code> via <code>color-mix()</code>), so the whole accent-tinted surface updates in one paint.</p>\n<h2 id=\"syncing-the-giscus-theme\" tabindex=\"0\" data-toc-anchor=\"true\">Syncing the Giscus theme</h2>\n<p>Giscus is an iframe, so its theme does not inherit from the parent page CSS. The page has to postMessage the theme into the iframe whenever it changes.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function syncGiscusTheme(theme) {\n  const frame = document.querySelector('iframe.giscus-frame');\n  if (!frame) return;\n  const giscusTheme = theme === 'dark' ? 'dark' : 'light';\n  frame.contentWindow?.postMessage({ giscus: { setConfig: { theme: giscusTheme } } }, 'https://giscus.app');\n}</code></pre><p>A MutationObserver on <code>document.documentElement</code> watches for <code>data-color-scheme</code> changes and calls <code>syncGiscusTheme</code>. That keeps comments visually in sync with the rest of the page. Without this, switching from dark to light leaves the comments in a contrast mismatch.</p>\n<h2 id=\"keyboard-focus-motion\" tabindex=\"0\" data-toc-anchor=\"true\">Keyboard, focus, motion</h2>\n<p>The toggle is a real <code>&lt;button&gt;</code>, which means it is keyboard-focusable by default. Its focus ring uses <code>:focus-visible</code>, and clicking it does not steal focus.</p>\n<p>For users with <code>prefers-reduced-motion: reduce</code>, the accent color transition is disabled. That is a two-line CSS rule:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>body {\n  transition:\n    background-color 0.2s,\n    color 0.2s;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  body {\n    transition: none;\n  }\n}</code></pre><h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A theme switcher that does not flash, respects the system, persists the user's choice, and syncs third-party iframes is about 50 lines of code and one well-structured CSS layer. The trick is the inline pre-paint script and the attribute-plus-media-query architecture. Neither requires a framework.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>prefers-color-scheme</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>matchMedia</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/color-scheme?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Theme switcher without FART - web.dev</a></li><li data-reveal=\"\"><a href=\"https://giscus.app/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Giscus customization API</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>color-mix()</code> - MDN</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-07-dark-mode-accent-prefers-color-scheme.png",
      "date_published": "2026-07-07T00:00:00.000Z",
      "tags": [
        "design-systems",
        "css",
        "accessibility"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-05-css-properties-hierarchy.html",
      "url": "https://dout.dev/posts/2026-07-05-css-properties-hierarchy.html",
      "title": "CSS Properties Hierarchy (Or: How I Learned Custom Properties Can Replace Preprocessors)",
      "summary": "Context - The chaos of unorganized CSS",
      "content_html": "<h2 id=\"context-the-chaos-of-unorganized-css\" tabindex=\"0\" data-toc-anchor=\"true\">Context - The chaos of unorganized CSS</h2>\n<p>I've been writing CSS since before <code>border-radius</code> was a thing, back when we used <code>-moz-border-radius</code> and prayed everything to work well.</p>\n<p>I've seen preprocessors rise (LESS, Sass, Stylus, the holy war... what a mess) and watched CSS itself absorb every feature they offered, one spec at a time.</p>\n<p>And through all of it, one thing never changed: the absolute chaos of an unorganized ruleset.</p>\n<p>We all know about this: 400 lines of CSS where <code>color: red</code> is followed by <code>position: absolute</code> followed by three vendor-prefixed gradients followed by a <code>font-size</code> that overrides the one fifty lines up. It's not wrong, the cascade still resolves it, the browser still paints it, but it's <em>unnecessary friction</em>.</p>\n<p>Every time you touch that file, you waste a few seconds scanning for what you need. Those seconds add up to hours. Those hours add up to bugs.</p>\n<p>So I developed a system. A hierarchy that mirrors the browser's own rendering pipeline: <strong>outside-in, layout-first, paint-last</strong>.</p>\n<h2 id=\"the-hierarchy-every-ruleset-tells-a-story\" tabindex=\"0\" data-toc-anchor=\"true\">The hierarchy - every ruleset tells a story</h2>\n<p>The way I see it, every CSS ruleset tells a story, and the story has a logical narrative arc. The order isn't arbitrary - it's the same order the browser processes properties: <strong>computed values → layout → paint → compositing</strong>.</p>\n<p>Every time I break this order, I introduce a subtle cognitive tax on the next reader, myself included. Every time I follow it, the ruleset reads like a coherent paragraph instead of a shopping list.</p>\n<p><strong>1. Custom properties.</strong> These come first because they're the inputs. They're resolved by the cascade before <em>any</em> property does anything. You want them declared before they're consumed, and grouping them at the top makes the ruleset's \"API surface\" immediately visible.</p>\n<p><strong>2. Position.</strong> Next, because it takes the element <em>out of flow</em> before you even think about sizing or coloring it. Setting <code>position: absolute</code> after <code>width</code> is technically fine, but logically backwards - you're picking a layout strategy for the element before you decide its dimensions.</p>\n<p><strong>3. Display.</strong> Right after position, because it determines the formatting context. Flex, grid, block - the element needs to know <em>how to be a box</em> before you can meaningfully set <code>gap</code>, <code>place-content</code>, or <code>align-items</code>.</p>\n<p><strong>4. Opacity &amp; visibility.</strong> These affect the box's presence without touching layout. They're the border between layout and paint.</p>\n<p><strong>5. Box-model.</strong> The geometry: <strong>inside-out</strong> - content size first, then padding, then border, then margin. <code>box-sizing</code> at the top of the section because it changes the math for everything below.</p>\n<p><strong>6. Colors &amp; background.</strong> The paint layer. Text color, background, shadows, filters. These trigger repaints but not re-layouts (mostly).</p>\n<p><strong>7. Typography.</strong> After you've painted the background, you render the text on top. <code>font</code>, <code>line-height</code>, <code>text-align</code>, <code>white-space</code>, <code>text-shadow</code>.</p>\n<p><strong>8. Transforms &amp; animations.</strong> The compositor stage. <code>transform</code>, <code>transition</code>, <code>animation</code>. These are the last thing that happens visually, but also the most performance-sensitive - having them in one block makes it easy to audit <code>will-change</code> usage.</p>\n<p><strong>9. Helpers.</strong> <code>appearance</code>, <code>cursor</code>, <code>pointer-events</code> - utility properties that don't fit anywhere else.</p>\n<p><strong>10. Pseudo-elements.</strong> <code>&amp;::before</code>, <code>&amp;::after</code> - they're part of the element's visual tree, nested inside.</p>\n<p><strong>11. Variants &amp; pseudo-selectors.</strong> State changes: <code>&amp;.error</code>, <code>&amp;[aria-hidden]</code>, <code>&amp;:hover</code>. These override everything above, so they come last in the declaration block.</p>\n<p><strong>12. Media queries &amp; children.</strong> Scoping. <code>@media</code>, <code>&amp; span</code>, <code>&amp; &gt; *</code>. These are <em>new contexts</em> that restart the hierarchy - each is a fresh ruleset that follows the same order.</p>\n<h2 id=\"the-complete-example\" tabindex=\"0\" data-toc-anchor=\"true\">The complete example</h2>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --border-radius: 5px;\n}\n\n.element {\n  /* css custom properties */\n  --var--example: 1;\n\n  /* position */\n  position: absolute;\n  inset: 0; /* top, right, bottom, left */\n  z-index: 1;\n\n  /* display */\n  display: block;\n  display: flex;\n  place-content: center;\n  place-items: center;\n  justify-self: unset;\n  gap: 1rem;\n\n  opacity: 1;\n  visibility: visible;\n\n  /* box-model */\n  box-sizing: border-box;\n  width: 10rem;\n  aspect-ratio: 16 / 9;\n  padding: 1rem;\n  border: 0.1rem solid black;\n  border-radius: 0.4rem;\n  margin: 1rem;\n  outline: 0.3rem solid black;\n  outline-offset: 0.3rem;\n\n  /* colors &amp; background */\n  color: white;\n  background-color: black;\n  background-image: url();\n  box-shadow: rgba(50, 50, 50, 1);\n  filter: drop-shadow();\n\n  /* text */\n  font-family: 'Courier New', Courier, monospace;\n  font-size: 1rem;\n  font-weight: 700;\n  line-height: normal;\n  white-space: nowrap;\n  text-align: center;\n  text-shadow: none;\n\n  /* transform &amp; animations */\n  transform: translate();\n  transition: opacity 300ms ease-in, width 500ms linear;\n  will-change: opacity, width;\n  animation: test 300ms forwards alternate-reverse;\n\n  /* helpers */\n  appearance: none;\n  cursor: pointer;\n  pointer-event: none;\n\n  /* pseudo elements */\n  &amp;::after {\n  }\n\n  /* variants &amp; pseudo selectors */\n  &amp;.error {\n    color: red;\n  }\n\n  &amp;[aria-hidden=true] {\n    display: none;\n  }\n\n  /* pseudo selectors */\n  &amp;:hover {\n  }\n\n  /* media queries */\n  @media screen and (width &gt;= 1024px) {\n    /* repeat css hierarchy here */\n  }\n\n  /* ------------ children */\n  span {\n    /* repeat css hierarchy here */\n  }\n\n  input {\n    /* repeat css hierarchy here */\n  }\n\n  &gt; * {\n    /* repeat css hierarchy here */\n  }\n}</code></pre><p>Look at that ruleset. Now look at your last project's CSS. If the two don't look alike, you know what to do.</p>\n<h2 id=\"why-the-hierarchy-holds\" tabindex=\"0\" data-toc-anchor=\"true\">Why the hierarchy holds</h2>\n<p>I'm not going to tell you this hierarchy is the One True Way™ - I've been in this game long enough to know that CSS is a language, not a religion, and anyone who tells you there's exactly one correct way to order properties is selling something (probably a linter rule they wrote). But I <em>will</em> tell you this: having <em>any</em> consistent order is infinitely better than having no order at all. The specific convention matters less than the fact that you have one and you follow it.</p>\n<p>What I <em>will</em> claim is that this specific hierarchy has survived three major migrations, six codebases, twelve team members with varying skill levels, and zero arguments. Because it's not \"my opinion\" - it's the browser's rendering pipeline encoded as property order. You can't argue with the spec. Well, you can, but you'll lose.</p>\n<p>The beautiful thing is that once you internalize this order, you stop thinking about it. You just write properties in the order they naturally fall in your head, and they happen to match the hierarchy. It becomes as automatic as indenting nested blocks or putting spaces after commas. The ruleset writes itself, and the next person who opens that file doesn't curse your name. That's the real win.</p>\n<h2 id=\"the-preprocessor-epiphany\" tabindex=\"0\" data-toc-anchor=\"true\">The preprocessor epiphany</h2>\n<p>And yeah, about the preprocessor thing: I started using this hierarchy back when I was writing Sass. When CSS custom properties landed in browsers, I realized <code>--my-var</code> slotted perfectly into the top section of the hierarchy - custom properties first, always. Nesting (<code>&amp;</code> at the bottom for variants, pseudo-elements, children) was already how I organized Sass. The hierarchy didn't change when the tooling changed. The <em>language</em> caught up to the <em>discipline</em>.</p>\n<p>That's when it clicked: the hierarchy wasn't a Sass convention. It was a <em>CSS</em> convention that happened to work in Sass. And once native CSS nesting landed in 2023, I deleted my last <code>@use 'sass'</code>, not because I hate Sass (I don't), but because the hierarchy made it irrelevant. Discipline beats tooling every time. Always has.</p>\n<h2 id=\"a-challenge-for-you\" tabindex=\"0\" data-toc-anchor=\"true\">A challenge for you</h2>\n<p>So here's my challenge: pick any ruleset in your current project. Reorder it using this hierarchy. Don't change a single value, just move the lines around. Then read it again. I bet you find a bug, or a duplicate property you didn't notice, or an override that doesn't do what you thought. The hierarchy surfaces that stuff because <em>related things are next to each other</em> instead of scattered across 80 lines.</p>\n<p>And when you find that bug, think about this: the hierarchy found it, not a linter. A linter can tell you \"duplicate property detected.\" The hierarchy tells you <em>why</em> it's a duplicate and <em>which one wins</em>.</p>\n<p>Told you. CSS is a language, not a config file.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-05-css-properties-hierarchy.png",
      "date_published": "2026-07-05T00:00:00.000Z",
      "tags": [
        "html",
        "css"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-04-pragmatic-service-worker.html",
      "url": "https://dout.dev/posts/2026-07-04-pragmatic-service-worker.html",
      "title": "A Pragmatic Service Worker: Cache Strategy, Offline, No Abuse (80 Lines, No Drama)",
      "summary": "The case against most service workers",
      "content_html": "<h2 id=\"the-case-against-most-service-workers\" tabindex=\"0\" data-toc-anchor=\"true\">The case against most service workers</h2>\n<p>Service workers are the feature most likely to end up on a \"cool feature I added and then forgot about\" list. The tradeoffs are real:</p>\n<ul>\n<li data-reveal=\"\">A bug in your SW can break the site for returning visitors in ways that are hard to debug and slow to fix.</li><li data-reveal=\"\">A greedy caching strategy can serve stale content to users who would prefer fresh.</li><li data-reveal=\"\">Registering an SW at all commits you to a lifecycle - updates, skip-waiting, claim - that requires careful thought.</li></ul>\n<p>Given all that, why ship one? For a blog: the offline page. For a PWA: the install experience and the background resilience. For dout.dev, both.</p>\n<p>What I refuse to do is cache everything, intercept every request, and ship an SW that tries to be a runtime framework. The dout.dev SW is about 80 lines. This post is what is in it.</p>\n<h2 id=\"the-scope\" tabindex=\"0\" data-toc-anchor=\"true\">The scope</h2>\n<p>Three jobs, in order of importance.</p>\n<ol>\n<li data-reveal=\"\"><strong>Serve an offline fallback page</strong> when the network is unreachable and the user requests a page that is not in cache.</li><li data-reveal=\"\"><strong>Cache the critical shell</strong> - the home page, the main CSS, the primary script bundle - so the next visit is instant even on a cold network.</li><li data-reveal=\"\"><strong>Cache visited posts</strong> on a stale-while-revalidate basis, so re-reading a post is instant and returning to it offline works.</li></ol>\n<p>Everything else - images, feeds, analytics beacons, third-party assets - is not intercepted.</p>\n<h2 id=\"the-precache-list\" tabindex=\"0\" data-toc-anchor=\"true\">The precache list</h2>\n<p>At install time, the SW precaches exactly the files needed to render the offline experience. That list is generated by the build and inlined into the SW file, so there is no manifest to drift.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const PRECACHE = 'dout-precache-v1';\nconst RUNTIME = 'dout-runtime-v1';\n\nconst PRECACHE_URLS = ['/', '/offline.html', '/styles/index.css', '/scripts/main.js', '/assets/favicon.svg'];\n\nself.addEventListener('install', (event) =&gt; {\n  event.waitUntil(caches.open(PRECACHE).then((cache) =&gt; cache.addAll(PRECACHE_URLS)));\n});</code></pre><p>The precache name includes a version (<code>-v1</code>). Bumping that version on a release invalidates the precache cleanly.</p>\n<h2 id=\"the-activate-cleanup\" tabindex=\"0\" data-toc-anchor=\"true\">The activate cleanup</h2>\n<p>On activation, old caches get deleted. Without this, users accumulate dead caches forever.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>self.addEventListener('activate', (event) =&gt; {\n  const valid = new Set([PRECACHE, RUNTIME]);\n  event.waitUntil(\n    caches.keys().then((names) =&gt; Promise.all(names.filter((n) =&gt; !valid.has(n)).map((n) =&gt; caches.delete(n))))\n  );\n  self.clients.claim();\n});</code></pre><p><code>self.clients.claim()</code> takes control of existing tabs on the first activation, so the new SW is in charge immediately instead of after a full reload. That is a judgment call - some projects prefer to wait for a reload to avoid mid-session inconsistencies. For a read-only blog, claiming is safe.</p>\n<h2 id=\"the-fetch-handler-in-three-cases\" tabindex=\"0\" data-toc-anchor=\"true\">The fetch handler, in three cases</h2>\n<p>The fetch handler has three branches, and each is small.</p>\n<h3 id=\"1-html-navigation-requests\" tabindex=\"0\" data-toc-anchor=\"true\">1. HTML navigation requests</h3>\n<p>For navigation (a page load), try the network first. If the network wins, cache the response for later. If the network fails, serve the cached version, and if that is missing, the offline page.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>self.addEventListener('fetch', (event) =&gt; {\n  const req = event.request;\n  if (req.mode === 'navigate') {\n    event.respondWith(\n      fetch(req)\n        .then((res) =&gt; {\n          const copy = res.clone();\n          caches.open(RUNTIME).then((cache) =&gt; cache.put(req, copy));\n          return res;\n        })\n        .catch(() =&gt; caches.match(req).then((cached) =&gt; cached || caches.match('/offline.html')))\n    );\n    return;\n  }\n  // ...\n});</code></pre><p>Network-first for HTML means readers always get the freshest post when they are online. The cache is a fallback, not a source of truth.</p>\n<h3 id=\"2-same-origin-static-assets\" tabindex=\"0\" data-toc-anchor=\"true\">2. Same-origin static assets</h3>\n<p>For CSS, JS, and fonts on the same origin, cache-first with a background revalidate. This is the classic stale-while-revalidate pattern:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>if (req.destination === 'style' || req.destination === 'script' || req.destination === 'font') {\n  event.respondWith(\n    caches.match(req).then((cached) =&gt; {\n      const networkFetch = fetch(req).then((res) =&gt; {\n        const copy = res.clone();\n        caches.open(RUNTIME).then((cache) =&gt; cache.put(req, copy));\n        return res;\n      });\n      return cached || networkFetch;\n    })\n  );\n  return;\n}</code></pre><p>Returning cached content immediately keeps the page fast; the background fetch updates the cache for next time. The trade-off is that the first load after a deploy serves the old bundle, and the fresh one is picked up on the next navigation. For a blog, that is acceptable.</p>\n<h3 id=\"3-everything-else\" tabindex=\"0\" data-toc-anchor=\"true\">3. Everything else</h3>\n<p>Images, feeds, third-party URLs, analytics beacons - pass through to the network without touching the cache. The SW explicitly does not intercept.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// fall through to the network</code></pre><p>Not caching something is a decision too. It avoids the trap of caching things you never meant to cache and then not being able to invalidate them.</p>\n<h2 id=\"the-offline-page\" tabindex=\"0\" data-toc-anchor=\"true\">The offline page</h2>\n<p><code>offline.html</code> is a static page with the branding and a short message. It links to the home (which might be cached) and includes a small retry button that reloads the page.</p>\n<p>The only trick: the offline page must not reference uncached resources. If the offline CSS is not in the precache, the page still renders but unstyled. The precache list above includes the main CSS, so this works.</p>\n<h2 id=\"update-strategy\" tabindex=\"0\" data-toc-anchor=\"true\">Update strategy</h2>\n<p>The SW updates itself when the browser fetches <code>/sw.js</code> and notices it differs byte-for-byte from the registered one. Because the precache list contains a version string, a deploy that bumps the precache version triggers an update.</p>\n<p>For a blog, that is enough. I do not ship an explicit \"update available\" banner. The user gets the new SW on the next navigation after a deploy, and the cleanup handler deletes the old cache.</p>\n<h2 id=\"what-i-did-not-add\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not add</h2>\n<ul>\n<li data-reveal=\"\"><strong>Background sync.</strong> The blog has nothing to sync. Readers do not post content.</li><li data-reveal=\"\"><strong>Push notifications.</strong> Reader-initiated subscriptions belong to the RSS layer.</li><li data-reveal=\"\"><strong>Periodic background sync.</strong> Same reason.</li><li data-reveal=\"\"><strong>Navigation preload.</strong> A legitimate optimization, but it adds complexity I did not need for the current page load times.</li></ul>\n<p>Each of these is a feature I could add later without restructuring the SW. Keeping the current one small is the point.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A service worker on a blog is worth the 80 lines if you ship the three jobs: offline fallback, shell precache, and a conservative runtime cache for repeat visits. Resist the urge to intercept every request. The bugs you avoid by caching less are worth more than the performance gains from caching more.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Service Worker API - MDN</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/service-worker-lifecycle?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">The Service Worker Lifecycle - web.dev</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/offline-cookbook?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Offline cookbook - Jake Archibald</a></li><li data-reveal=\"\"><a href=\"https://developer.chrome.com/docs/workbox?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Workbox</a> - if you want pre-built recipes</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-04-pragmatic-service-worker.png",
      "date_published": "2026-07-04T00:00:00.000Z",
      "tags": [
        "performance",
        "vanilla-js",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-30-honest-lazy-loading.html",
      "url": "https://dout.dev/posts/2026-06-30-honest-lazy-loading.html",
      "title": "Honest Lazy Loading: IntersectionObserver vs Native loading=lazy (The Real Difference)",
      "summary": "The two tools, in one paragraph",
      "content_html": "<h2 id=\"the-two-tools-in-one-paragraph\" tabindex=\"0\" data-toc-anchor=\"true\">The two tools, in one paragraph</h2>\n<p>Native <code>loading=\"lazy\"</code> on <code>&lt;img&gt;</code> and <code>&lt;iframe&gt;</code> is a browser-managed hint. The browser decides when to load based on its own heuristics, and it usually does a good job. IntersectionObserver is a programmable primitive that tells your code exactly when an element enters a viewport band. Both are useful. They are not the same thing, and treating them as interchangeable is how you end up with images that load too late or scripts that fire too early.</p>\n<p>This post is the mental model I use to pick between them.</p>\n<h2 id=\"the-happy-path-native-lazy-on-images-below-the-fold\" tabindex=\"0\" data-toc-anchor=\"true\">The happy path: native lazy on images below the fold</h2>\n<p>For most images on a blog, native lazy loading is correct and sufficient.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img src=\"keyboard.jpg\" alt=\"A keyboard on a wooden desk\" width=\"1920\" height=\"1280\" loading=\"lazy\" decoding=\"async\" /&gt;</code></pre><p>What this gets you, for free:</p>\n<ul>\n<li data-reveal=\"\">The image does not download until the browser predicts it is needed.</li><li data-reveal=\"\">No JavaScript required.</li><li data-reveal=\"\">The <code>decoding=\"async\"</code> hint tells the browser it can decode off the main thread.</li><li data-reveal=\"\"><code>width</code> and <code>height</code> reserve the aspect-ratio box so there is no layout shift when the image eventually arrives.</li></ul>\n<p>The browser's heuristic is not perfect, but it is tuned for the 80% case of scrolling pages. If your images are content images on a standard reading flow, <code>loading=\"lazy\"</code> does the job.</p>\n<h2 id=\"where-native-lazy-is-wrong\" tabindex=\"0\" data-toc-anchor=\"true\">Where native lazy is wrong</h2>\n<p>Three situations where you should not use <code>loading=\"lazy\"</code>, or where you have to combine it with IntersectionObserver.</p>\n<h3 id=\"1-lcp-candidates-above-the-fold\" tabindex=\"0\" data-toc-anchor=\"true\">1. LCP candidates above the fold</h3>\n<p>The image that is going to be your Largest Contentful Paint should load eagerly. Setting <code>loading=\"lazy\"</code> on an LCP image delays the one number that most affects your page quality metric.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img src=\"hero.jpg\" alt=\"Hero image\" width=\"1200\" height=\"800\" loading=\"eager\" fetchpriority=\"high\" /&gt;</code></pre><p><code>fetchpriority=\"high\"</code> tells the browser this resource should be prioritized over others. Use it sparingly - if everything is \"high\", nothing is. One LCP candidate per page.</p>\n<h3 id=\"2-source-inside-picture\" tabindex=\"0\" data-toc-anchor=\"true\">2. <code>&lt;source&gt;</code> inside <code>&lt;picture&gt;</code></h3>\n<p><code>loading=\"lazy\"</code> on an <code>&lt;img&gt;</code> inside a <code>&lt;picture&gt;</code> applies to the image as a whole. But the <code>&lt;source&gt;</code> elements inside the picture have already been evaluated by the time the browser decides whether to defer the image. The browser will still download a WebP variant even if the fallback <code>&lt;img&gt;</code> is lazy.</p>\n<p>For large below-the-fold <code>&lt;picture&gt;</code> blocks, the fix is to store the <code>srcset</code> in <code>data-srcset</code> and swap it with IntersectionObserver when the element is near the viewport.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;picture&gt;\n  &lt;source\n    type=\"image/webp\"\n    data-srcset=\"/img/hero-320.webp 320w, /img/hero-640.webp 640w\"\n    sizes=\"(max-width: 640px) 100vw, 640px\"\n  /&gt;\n  &lt;img src=\"/img/hero.jpg\" alt=\"...\" width=\"1920\" height=\"1280\" loading=\"lazy\" /&gt;\n&lt;/picture&gt;</code></pre><pre is=\"pix-highlighter\" data-lang=\"js\"><code>const io = new IntersectionObserver(\n  (entries) =&gt; {\n    for (const entry of entries) {\n      if (!entry.isIntersecting) continue;\n      const source = entry.target;\n      source.srcset = source.dataset.srcset;\n      source.removeAttribute('data-srcset');\n      io.unobserve(source);\n    }\n  },\n  { rootMargin: '200px' }\n);\n\ndocument.querySelectorAll('source[data-srcset]').forEach((s) =&gt; io.observe(s));</code></pre><p>This is the only reliable way I have found to avoid eager downloads of large WebP variants for below-the-fold pictures.</p>\n<h3 id=\"3-iframes-from-third-parties\" tabindex=\"0\" data-toc-anchor=\"true\">3. Iframes from third parties</h3>\n<p>Giscus, CodePen embeds, video embeds: these are expensive third-party resources that you do not want to load on every page view. <code>loading=\"lazy\"</code> on the iframe helps, but you often want stricter control - load only when the user scrolls close, or only when a \"Show comments\" button is pressed.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;div class=\"comments-shell\" data-giscus-src=\"https://giscus.app/client.js\" data-giscus-attrs='{ \"data-repo\": \"...\" }'&gt;\n  &lt;button type=\"button\" class=\"load-comments\"&gt;Load comments&lt;/button&gt;\n&lt;/div&gt;</code></pre><pre is=\"pix-highlighter\" data-lang=\"js\"><code>document.querySelector('.load-comments')?.addEventListener('click', (e) =&gt; {\n  const shell = e.target.closest('.comments-shell');\n  const attrs = JSON.parse(shell.dataset.giscusAttrs);\n  const script = document.createElement('script');\n  script.src = shell.dataset.giscusSrc;\n  for (const [k, v] of Object.entries(attrs)) script.setAttribute(k, v);\n  script.crossOrigin = 'anonymous';\n  script.async = true;\n  shell.appendChild(script);\n  e.target.remove();\n});</code></pre><p>The cost of opting into \"click to load\" for comments is one button and a 20-line handler. The benefit is a significantly smaller critical path for readers who do not engage with comments.</p>\n<p>On dout.dev I ship the Giscus embed lazily via <code>data-loading=\"lazy\"</code> and do not hide it behind a button, because comments are part of the editorial experience. On a page with heavier embeds, the button pattern is the right default.</p>\n<h2 id=\"the-mental-model\" tabindex=\"0\" data-toc-anchor=\"true\">The mental model</h2>\n<ul>\n<li data-reveal=\"\">Is the element above the fold and likely the LCP? → <code>loading=\"eager\"</code> + <code>fetchpriority=\"high\"</code>.</li><li data-reveal=\"\">Is it an image or plain iframe below the fold? → <code>loading=\"lazy\"</code>.</li><li data-reveal=\"\">Is it a <code>&lt;source&gt;</code> inside a <code>&lt;picture&gt;</code> below the fold? → IntersectionObserver with <code>data-srcset</code> swap.</li><li data-reveal=\"\">Is it a heavy third-party embed? → IntersectionObserver with a bigger root margin, or a click-to-load button.</li></ul>\n<p>Everything else is a variation.</p>\n<h2 id=\"what-i-do-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not do</h2>\n<p>I do not reinvent native lazy for plain images. I do not ship a \"lazyload.js\" dependency. I do not observe scroll events. IntersectionObserver is already in every target browser and has been for years.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Native lazy is a good default. Use it first. Reach for IntersectionObserver when the browser's heuristic is not under your control (nested picture sources, third-party iframes, expensive runtime costs). The two tools complement each other; they do not compete.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/loading?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>loading</code> attribute - MDN</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/fetchpriority?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>fetchpriority</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">IntersectionObserver - MDN</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/browser-level-image-lazy-loading?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Browser-level image lazy loading - web.dev</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/iframe-lazy-loading?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Lazy-loading iframes - web.dev</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-30-honest-lazy-loading.png",
      "date_published": "2026-06-30T00:00:00.000Z",
      "tags": [
        "performance",
        "frontend",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-27-accessible-scrollspy-outline.html",
      "url": "https://dout.dev/posts/2026-06-27-accessible-scrollspy-outline.html",
      "title": "Accessible Scrollspy and Outline Without contenteditable (Yes, It Can Be Done)",
      "summary": "The feature and its trap",
      "content_html": "<h2 id=\"the-feature-and-its-trap\" tabindex=\"0\" data-toc-anchor=\"true\">The feature and its trap</h2>\n<p>A long post benefits from a sidebar outline that highlights the current section as the user scrolls. On a blog with 1500-word articles, it is the difference between a linear reading experience and a navigable document.</p>\n<p>Most scrollspy implementations have three accessibility problems.</p>\n<ol>\n<li data-reveal=\"\">They rely on the browser's scroll event, which fires too often and updates the active state with a jitter that confuses screen readers.</li><li data-reveal=\"\">They make the headings focusable in ways that break keyboard expectations - <code>tabindex=\"0\"</code> on every <code>h2</code> is a trap, not a feature.</li><li data-reveal=\"\">They announce the current section via <code>aria-current</code> that changes several times per second during scroll, which turns the outline into a screaming live region.</li></ol>\n<p>This post is the design I landed on after tripping over all three.</p>\n<h2 id=\"the-dom-shape\" tabindex=\"0\" data-toc-anchor=\"true\">The DOM shape</h2>\n<p>The outline is a normal <code>&lt;nav&gt;</code> with a list of anchor links to heading IDs. That is the document you would get from a static site with no JavaScript. Everything else is enhancement.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;aside class=\"post-outline\"&gt;\n  &lt;nav aria-label=\"Article outline\"&gt;\n    &lt;ol&gt;\n      &lt;li&gt;\n        &lt;a href=\"#the-feature-and-its-trap\"&gt;The feature and its trap&lt;/a&gt;\n      &lt;/li&gt;\n      &lt;li&gt;\n        &lt;a href=\"#the-dom-shape\"&gt;The DOM shape&lt;/a&gt;\n      &lt;/li&gt;\n      &lt;li&gt;\n        &lt;a href=\"#observing-headings-not-scroll\"&gt;Observing headings, not scroll&lt;/a&gt;\n      &lt;/li&gt;\n    &lt;/ol&gt;\n  &lt;/nav&gt;\n&lt;/aside&gt;</code></pre><p>Without JS: you get a jump-link navigation. With JS: the active link gets <code>aria-current=\"location\"</code> based on what is currently visible.</p>\n<h2 id=\"headings-have-ids-and-they-are-focusable-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">Headings have IDs, and they are focusable on purpose</h2>\n<p>The post generator emits IDs on every heading (<code>## The DOM shape</code> → <code>id=\"the-dom-shape\"</code>). It also adds <code>tabindex=\"-1\"</code> to headings, so they can receive programmatic focus when a user activates an outline link. Without that, focus remains on the link that was activated, and the next tab stop is inside the link list instead of the section body.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;h2 id=\"the-dom-shape\" tabindex=\"-1\"&gt;The DOM shape&lt;/h2&gt;</code></pre><p><code>tabindex=\"-1\"</code> makes the heading programmatically focusable without adding it to the tab order. That is the shape you want for anchor targets. <code>tabindex=\"0\"</code> would make every heading a tab stop and is wrong.</p>\n<h2 id=\"observing-headings-not-scroll\" tabindex=\"0\" data-toc-anchor=\"true\">Observing headings, not scroll</h2>\n<p>Listening to <code>scroll</code> and computing which heading is \"current\" is a trap. It fires constantly, it does not know about the viewport's relevance cone, and it forces you to recompute heading positions on resize.</p>\n<p>The right primitive is IntersectionObserver. Point it at the headings with a top-biased root margin, and let it tell you when headings enter or leave the relevance zone.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const headings = document.querySelectorAll('article h2[id], article h3[id]');\nconst outline = document.querySelector('.post-outline');\n\nlet currentId = null;\n\nconst io = new IntersectionObserver(\n  (entries) =&gt; {\n    for (const entry of entries) {\n      if (entry.isIntersecting) {\n        currentId = entry.target.id;\n      }\n    }\n    if (currentId) updateOutline(currentId);\n  },\n  {\n    rootMargin: '-20% 0px -70% 0px',\n    threshold: 0,\n  }\n);\n\nfor (const h of headings) io.observe(h);</code></pre><p>The <code>rootMargin</code> shrinks the \"active\" band to the upper part of the viewport. A heading is \"current\" when it enters that band, which matches the reader's expectation.</p>\n<h2 id=\"updating-the-outline-without-thrashing-aria\" tabindex=\"0\" data-toc-anchor=\"true\">Updating the outline without thrashing ARIA</h2>\n<p>The update function removes <code>aria-current</code> from all outline links and adds it only to the active one. That is a tiny DOM change, not a re-render.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function updateOutline(id) {\n  const active = outline.querySelector('[aria-current]');\n  if (active) active.removeAttribute('aria-current');\n\n  const next = outline.querySelector(`a[href=\"#${CSS.escape(id)}\"]`);\n  if (next) next.setAttribute('aria-current', 'location');\n}</code></pre><p><code>aria-current=\"location\"</code> is the correct value for \"this link points at the user's current location in the document.\" <code>aria-current=\"page\"</code> is wrong here; that one is for pagination or site navigation.</p>\n<h2 id=\"throttling-is-not-needed\" tabindex=\"0\" data-toc-anchor=\"true\">Throttling is not needed</h2>\n<p>IntersectionObserver is already asynchronous and batched. Callbacks fire at animation-frame cadence at most, and only when the observed elements actually cross a threshold. No <code>requestAnimationFrame</code> wrapper, no <code>throttle</code>, no debouncer. Writing one of those on top of IntersectionObserver is a code smell.</p>\n<h2 id=\"smooth-scroll-and-focus-after-link-click\" tabindex=\"0\" data-toc-anchor=\"true\">Smooth scroll and focus after link click</h2>\n<p>When the user clicks an outline link, the default behavior jumps to the anchor. On dout.dev the behavior is enhanced: smooth-scroll to the heading, then move focus to the heading so that subsequent tab keys land inside the section.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>outline.addEventListener('click', (e) =&gt; {\n  const link = e.target.closest('a[href^=\"#\"]');\n  if (!link) return;\n  e.preventDefault();\n\n  const id = decodeURIComponent(link.hash.slice(1));\n  const target = document.getElementById(id);\n  if (!target) return;\n\n  target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n  target.focus({ preventScroll: true });\n\n  history.pushState(null, '', `#${id}`);\n});</code></pre><p>Two subtleties.</p>\n<p><strong><code>preventScroll: true</code> on focus.</strong> Without it, <code>focus()</code> scrolls the heading to the top of the viewport, which fights the smooth-scroll animation.</p>\n<p><strong><code>history.pushState</code> instead of assigning <code>location.hash</code>.</strong> Setting the hash re-triggers the native jump and cancels the smooth scroll. Pushing the URL manually gives the user a shareable link without breaking the animation.</p>\n<h2 id=\"reduced-motion\" tabindex=\"0\" data-toc-anchor=\"true\">Reduced motion</h2>\n<p>Anyone with <code>prefers-reduced-motion: reduce</code> gets an instant scroll instead of smooth.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\ntarget.scrollIntoView({\n  behavior: reduceMotion ? 'auto' : 'smooth',\n  block: 'start',\n});</code></pre><p>The cost is one line. The benefit is that users with vestibular sensitivity do not get attacked by your animations.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A good scrollspy is three primitives the platform already gives you: IDs on headings, <code>tabindex=\"-1\"</code> on the targets, and IntersectionObserver for the activation logic. Anything more than that is a leak.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">IntersectionObserver - MDN</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/TR/wai-aria-1.2/?from=dout.dev#aria-current\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>aria-current</code> - W3C ARIA</a></li><li data-reveal=\"\"><a href=\"https://html.spec.whatwg.org/multipage/interaction.html?from=dout.dev#attr-tabindex\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>tabindex</code> - HTML Living Standard</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>prefers-reduced-motion</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.2 2.4.11 Focus Not Obscured (Minimum)</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-27-accessible-scrollspy-outline.png",
      "date_published": "2026-06-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "vanilla-js",
        "frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-23-custom-element-syntax-highlight.html",
      "url": "https://dout.dev/posts/2026-06-23-custom-element-syntax-highlight.html",
      "title": "A Custom Element for Syntax Highlight: Why I Wrote `pix-highlighter`",
      "summary": "The stack I did not want",
      "content_html": "<h2 id=\"the-stack-i-did-not-want\" tabindex=\"0\" data-toc-anchor=\"true\">The stack I did not want</h2>\n<p>Syntax highlighting on a blog is one of those problems with three plausible solutions, all of which bring a tax.</p>\n<ul>\n<li data-reveal=\"\"><strong>Prism or highlight.js at runtime.</strong> Small API, reasonable language support, but it ships JavaScript to every reader even for cold visits that never view code.</li><li data-reveal=\"\"><strong>Shiki at build time.</strong> Produces beautiful, VS Code-parity output, but pulls a full TextMate grammar engine into the build. The dependency graph is non-trivial and the output HTML is dense.</li><li data-reveal=\"\"><strong>Pygments or Rouge via a Ruby dependency.</strong> Excellent output, but I did not want a second-language toolchain in the pipeline.</li></ul>\n<p>For dout.dev I wanted something smaller than all three, with full control over the CSS tokens. The result is <code>pix-highlighter</code>, a custom element that takes the markdown fenced-code output, tokenizes on the client with a small lexer, and emits <code>&lt;span&gt;</code> tags keyed to design system tokens.</p>\n<p>That choice has trade-offs. This post explains them honestly.</p>\n<h2 id=\"what-the-renderer-emits\" tabindex=\"0\" data-toc-anchor=\"true\">What the renderer emits</h2>\n<p>The markdown renderer does not highlight. It emits structural markup:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;pre is=\"pix-highlighter\" lang=\"js\"&gt;\n  &lt;code&gt;function hello() { return 42; }&lt;/code&gt;\n&lt;/pre&gt;</code></pre><p><code>&lt;pre is=\"pix-highlighter\" lang=\"js\"&gt;</code> is a customized built-in element. It upgrades <code>&lt;pre&gt;</code> with new behavior while keeping the semantic element intact. Screen readers and copy-paste behave correctly; the upgrade is purely visual.</p>\n<h2 id=\"the-custom-element\" tabindex=\"0\" data-toc-anchor=\"true\">The custom element</h2>\n<p>The element is under 300 lines. It knows how to:</p>\n<ol>\n<li data-reveal=\"\">Read <code>lang</code> and pick the lexer.</li><li data-reveal=\"\">Tokenize the text content into <code>{ type, value }</code> tuples.</li><li data-reveal=\"\">Render a sequence of <code>&lt;span class=\"tok-&lt;type&gt;\"&gt;</code> wrapping the tokens.</li><li data-reveal=\"\">Expose a <code>copy</code> button that puts the raw source on the clipboard.</li></ol>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>class PixHighlighter extends HTMLPreElement {\n  connectedCallback() {\n    const code = this.querySelector('code');\n    if (!code || this.dataset.highlighted) return;\n\n    const lang = this.getAttribute('lang');\n    const lexer = LEXERS[lang];\n    if (!lexer) return;\n\n    const tokens = lexer(code.textContent);\n    code.innerHTML = tokens.map((t) =&gt; `&lt;span class=\"tok-${t.type}\"&gt;${escapeHtml(t.value)}&lt;/span&gt;`).join('');\n\n    this.dataset.highlighted = 'true';\n    this.appendCopyButton(code.textContent);\n  }\n}\n\ncustomElements.define('pix-highlighter', PixHighlighter, { extends: 'pre' });</code></pre><p>The element only runs where <code>&lt;pre is=\"pix-highlighter\"&gt;</code> exists in the DOM. The bulk of the site - every page without a code block - pays nothing for it.</p>\n<h2 id=\"the-lexers-are-small-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">The lexers are small on purpose</h2>\n<p>Each lexer is a single function that walks the string once and emits tokens. The language coverage is intentionally narrow: JS, TS, CSS, HTML, JSON, Bash, Python, Go, Rust, C, C++, PHP, C#, YAML, Markdown.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function lexJs(source) {\n  const tokens = [];\n  let i = 0;\n  while (i &lt; source.length) {\n    const rest = source.slice(i);\n    let m;\n    if ((m = rest.match(/^\\/\\/[^\\n]*/))) {\n      tokens.push({ type: 'comment', value: m[0] });\n    } else if ((m = rest.match(/^\"(?:[^\"\\\\]|\\\\.)*\"/))) {\n      tokens.push({ type: 'string', value: m[0] });\n    } else if ((m = rest.match(/^\\b(function|return|const|let|var|if|else|for)\\b/))) {\n      tokens.push({ type: 'keyword', value: m[0] });\n    } else if ((m = rest.match(/^\\d+(?:\\.\\d+)?/))) {\n      tokens.push({ type: 'number', value: m[0] });\n    } else if ((m = rest.match(/^\\s+/))) {\n      tokens.push({ type: 'ws', value: m[0] });\n    } else {\n      tokens.push({ type: 'text', value: source[i] });\n      i += 1;\n      continue;\n    }\n    i += m[0].length;\n  }\n  return tokens;\n}</code></pre><p>This is not correct in the \"TextMate-grade\" sense. It does not understand JSX, template literal interpolation, or JSDoc. It is correct enough for blog code samples, which are short, self-contained, and visually parseable.</p>\n<p>If I wanted the last 5% of fidelity, I would use Shiki. I did not.</p>\n<h2 id=\"the-css-is-design-system-tokens-not-theme-files\" tabindex=\"0\" data-toc-anchor=\"true\">The CSS is design system tokens, not theme files</h2>\n<p>Because the element emits <code>&lt;span class=\"tok-string\"&gt;</code> and similar, the CSS lives in the design system. Colors reference semantic tokens, which means the highlighter follows the theme switcher automatically.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>pre[is='pix-highlighter'] {\n  background: var(--color-code-bg);\n  color: var(--color-code-fg);\n  padding: var(--space-4);\n  border-radius: var(--radius-2);\n  font: var(--font-mono);\n}\n\n.tok-comment {\n  color: var(--color-code-comment);\n  font-style: italic;\n}\n.tok-string {\n  color: var(--color-code-string);\n}\n.tok-keyword {\n  color: var(--color-code-keyword);\n  font-weight: 600;\n}\n.tok-number {\n  color: var(--color-code-number);\n}</code></pre><p>No separate \"light theme\" and \"dark theme\" stylesheets. One set of rules, driven by semantic tokens, which flip based on <code>data-color-scheme</code>.</p>\n<h2 id=\"accessibility\" tabindex=\"0\" data-toc-anchor=\"true\">Accessibility</h2>\n<p>The copy-to-clipboard button has a visible label and an accessible name. The <code>&lt;pre&gt;</code> has a semantic code region, the <code>&lt;code&gt;</code> inside keeps the text content intact, and the token spans are decorative - aria-hidden would be wrong because they do contain the text the screen reader should read; the tokens are styling, not semantics.</p>\n<p>On a keyboard-only pass, the copy button receives focus with a visible ring, press fires the copy, and <code>aria-live=\"polite\"</code> on a sibling span announces \"Copied.\"</p>\n<h2 id=\"when-i-would-not-do-this\" tabindex=\"0\" data-toc-anchor=\"true\">When I would not do this</h2>\n<p>If the blog needed twenty languages with accurate semantic highlighting (JSX, template literals, complex macro systems), the cost of maintaining a handwritten lexer family would exceed the cost of adopting Shiki at build time. The trade-off is genuinely a spectrum.</p>\n<p>The cutoff I used: fewer than twenty languages, short code samples, theme integration matters, bundle size matters, fidelity at the 95% level is acceptable. Write your own. Otherwise, use Shiki.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Custom elements are underrated. A ~300-line <code>pix-highlighter</code> replaces a dependency I would have carried forever, integrates with the design system instead of a theme file, and only runs where it is needed. That pattern - small, scoped, declarative - fits the rest of dout.dev.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Web Components: Custom Elements - MDN</a></li><li data-reveal=\"\"><a href=\"https://html.spec.whatwg.org/multipage/custom-elements.html?from=dout.dev#customized-built-in-elements\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Customized built-in elements - HTML Living Standard</a></li><li data-reveal=\"\"><a href=\"https://shiki.style/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Shiki</a> - if you need TextMate-grade output</li><li data-reveal=\"\"><a href=\"https://prismjs.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Prism</a> - if you need runtime highlighting with minimal setup</li><li data-reveal=\"\"><a href=\"https://highlightjs.org/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Highlight.js</a> - the other runtime option</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-23-custom-element-syntax-highlight.png",
      "date_published": "2026-06-23T00:00:00.000Z",
      "tags": [
        "vanilla-js",
        "architecture",
        "frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-20-pi-dev-deepseek-v4-flash-daily-loop.html",
      "url": "https://dout.dev/posts/2026-06-20-pi-dev-deepseek-v4-flash-daily-loop.html",
      "title": "Pi and DeepSeek V4 Flash: The Daily Coding Loop That Costs Almost Nothing",
      "summary": "The headline numbers",
      "content_html": "<h2 id=\"the-headline-numbers\" tabindex=\"0\" data-toc-anchor=\"true\">The headline numbers</h2>\n<p>I will get the math out of the way first, because the cost is the part that surprises people.</p>\n<ul>\n<li data-reveal=\"\"><strong>Pi</strong> is an open-source terminal coding agent. MIT-licensed, written in TypeScript, four core tools (<code>read</code>, <code>write</code>, <code>edit</code>, <code>bash</code>) plus three opt-in read-only ones (<code>grep</code>, <code>find</code>, <code>ls</code>). No plan mode, no sub-agents, no permission popups, no IDE lock-in.</li><li data-reveal=\"\"><strong>DeepSeek V4 Flash</strong> is a 284B-parameter Mixture-of-Experts model with only 13B active per token, a 1M-token context window, and pricing that lands at <strong>$0.14 per million input tokens</strong> (cache miss), <strong>$0.0028 per million input tokens</strong> (cache hit), and <strong>$0.28 per million output tokens</strong> on the official API. Open weights, MIT license.</li><li data-reveal=\"\">A typical agent-loop session of mine reads, edits, and writes across a few hundred kilobytes of context, runs a few tool calls, and produces a few thousand tokens of reasoning. The bill is <strong>fractions of a cent</strong>.</li><li data-reveal=\"\">A heavy day, the kind where I ship two milestones and refactor a subsystem end-to-end, lands somewhere between <strong>$0.30 and $0.90</strong>.</li></ul>\n<p>That is the actual economic story of this combination. It is not a benchmark artifact. It is the loop I work in every day. This post is the full setup, the daily workflow, the cost math, the things it does not do well, and why I now treat it as the default.</p>\n<h2 id=\"what-pi-dev-is-and-what-it-deliberately-is-not\" tabindex=\"0\" data-toc-anchor=\"true\">What pi.dev is, and what it deliberately is not</h2>\n<p>Pi is the work of Mario Zechner, now maintained under the <code>earendil-works</code> GitHub organization. It is a terminal-native coding harness: you run it, it reads your repo, it proposes edits, it runs shell commands, and you review every change before it lands on disk. The repository sits at around 66.5K stars, and it has a very strong opinion about what a coding agent should be.</p>\n<p>The opinion is <strong>minimalism without being stupid</strong>.</p>\n<p>Pi ships with exactly four core tools: <code>read</code>, <code>write</code>, <code>edit</code>, and <code>bash</code>. Three more, <code>grep</code>, <code>find</code>, and <code>ls</code>, are available as opt-in read-only tools. That is the entire built-in surface. Everything else is a TypeScript extension, a skill, a prompt template, or a theme. There is no plan mode because you can write a prompt template that asks the model to plan first. There is no sub-agent system because you can write a skill that delegates. There is no permission system because you are expected to read the diff before the model writes to disk.</p>\n<p>That last point is the one most people miss. Pi does not remove safety. Pi removes <strong>friction</strong>. You still review every change. You still see the exact command before it runs. You still decide when the loop is done. What you give up is a layer of confirmation dialogs that, in my experience, do not actually catch the kind of mistakes that matter and mostly just slow down the parts of the work that are already correct.</p>\n<p>And the entire thing is <strong>open source at its core</strong>. The repository at <a href=\"https://github.com/earendil-works/pi?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">github.com/earendil-works/pi</a> is MIT-licensed, and everything - the core loop, the tool harness, the MCP bridge, the provider abstraction - is readable, forkable, and hackable. You can strip it down, patch it, or build your own distribution. There is no proprietary layer, no closed-source enterprise edition, no telemetry-gated feature. The CLI, the TUI, the extension system, and the skill loader are all there in plain TypeScript.</p>\n<p>The consequence is that the extension API is unusually clean. Pi exposes a small, well-documented set of primitives - tools, MCP servers, skills, prompt templates, and themes - and you wire them together with a <code>package.json</code> and a single <code>main</code> file. The package manager (<code>pi packages</code>) discovers, installs, and updates extensions from <a href=\"https://pi.dev/packages/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">pi.dev/packages</a>, where anyone can publish.</p>\n<p>I maintain a small extension myself: <a href=\"https://pi.dev/packages/@pixu1980/pi-path-picker?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>@pixu1980/pi-path-picker</code></a>, a tool that autocompletes file paths inside the agent prompt. The source lives at <a href=\"https://github.com/pixu1980/pi-coding-agent-extensions?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">github.com/pixu1980/pi-coding-agent-extensions</a>, and the entire implementation - registering a custom tool, hooking into the prompt lifecycle, handling tab-completion in the TUI - fits in a handful of files. It is a good example of how little ceremony is involved: you write a TypeScript class, export it, publish it, and it works. No build step beyond TypeScript, no configuration wizard, no permission manifest.</p>\n<p>The other thing Pi gets right is the provider model. It supports 20+ providers out of the box, including Anthropic, OpenAI, Google, xAI, Mistral, Groq, OpenRouter, and DeepSeek. DeepSeek is a first-class native provider because it speaks the OpenAI-compatible API, and you can switch models mid-session. The configuration lives in <code>~/.pi/agent/models.json</code> and you can register as many models as you want.</p>\n<p>I have seven models registered at any given time. I switch between them depending on the task. The default for almost everything I do is DeepSeek V4 Flash.</p>\n<h2 id=\"what-deepseek-v4-flash-actually-is\" tabindex=\"0\" data-toc-anchor=\"true\">What DeepSeek V4 Flash actually is</h2>\n<p>DeepSeek V4 launched in preview on <strong>April 24, 2026</strong>, in two variants. V4-Pro is the flagship: 1.6T total parameters, 49B active, around $1.74 per million input tokens and $3.48 per million output tokens. V4-Flash is the cost-optimized tier: 284B total, <strong>13B active per token</strong>, 1M context window, 2,500 concurrent requests, and the pricing I quoted above.</p>\n<p>The interesting design choice is the MoE split. 284B sounds enormous, but only 13B parameters run on any given token, which is what gives Flash its cost and latency profile. The model is not a \"small model that tries to look big\" - it is a properly sparse MoE that pays a small compute bill per token while still benefiting from a much larger knowledge base when routing. DeepSeek also uses a sparse attention scheme (CSA / HCA) to keep the 1M-context long-tail cheap, which is exactly what an agent loop needs.</p>\n<p>On coding benchmarks, V4-Flash lands around <strong>79% on SWE-bench Verified</strong> and <strong>96% on HumanEval</strong> in third-party reporting, with a gap of about 1.6 percentage points to V4-Pro on SWE-bench Verified and around 1.9 points on LiveCodeBench. That is not the kind of gap I care about for the kind of work I do with it.</p>\n<p>The other important fact is that V4-Flash ships under an <strong>MIT license for the weights</strong>. The API name is <code>deepseek-v4-flash</code>, the older <code>deepseek-chat</code> and <code>deepseek-reasoner</code> aliases retire on <strong>July 24, 2026</strong>, and the integration with Pi is officially documented on the DeepSeek API docs, not just tolerated.</p>\n<h2 id=\"the-daily-loop\" tabindex=\"0\" data-toc-anchor=\"true\">The daily loop</h2>\n<p>Here is the loop, condensed.</p>\n<ol>\n<li data-reveal=\"\"><strong>Start the agent in the project root.</strong> <code>pi</code> runs in the terminal, reads the project context, and shows me the current state of the working tree.</li><li data-reveal=\"\"><strong>State the outcome in one paragraph.</strong> Not a task list. A paragraph a smart colleague could act on cold. \"Refactor the search indexer to use a prebuilt JSON dataset. Match the conventions in <code>scripts/cms/_index.js</code>. Do not touch the post template. Return the new file and the minimal diff to wire it up.\"</li><li data-reveal=\"\"><strong>Let the agent run the tool loop.</strong> It reads files, runs <code>pnpm test</code>, runs <code>pnpm lint</code>, edits the right places, and reports back. Most of my sessions are 5 to 20 tool calls.</li><li data-reveal=\"\"><strong>Read the diff.</strong> This is the part I never skip. Pi shows me exactly what changed, in which files, and I approve, reject, or steer.</li><li data-reveal=\"\"><strong>Ship.</strong> Commit, push, let CI do the rest.</li></ol>\n<p>The 1M context window matters more than I expected. Most of my projects are well under that, but the long-context behavior is what makes the agent loop feel cheap rather than expensive. When the model can hold the whole architecture in memory, it stops asking redundant questions, stops re-reading files, and stops producing context-degrading summaries of files it has already read.</p>\n<h2 id=\"the-cost-math-in-real-numbers\" tabindex=\"0\" data-toc-anchor=\"true\">The cost math, in real numbers</h2>\n<p>Let me be specific, because vague cost claims are useless.</p>\n<p>A typical milestone on dout.dev has been something like a CMS build step, a new template, or a content migration. The agent loop for one of those usually looks like:</p>\n<ul>\n<li data-reveal=\"\">200k–500k tokens of input across the session, most of which is the system prompt, the project context, and the files being read.</li><li data-reveal=\"\">5k–20k tokens of output, which includes reasoning, diffs, and the final response.</li><li data-reveal=\"\">Most of those input tokens are <strong>cache hits</strong> after the first turn, because Pi re-sends the same system prompt and the same long project context on every iteration.</li></ul>\n<p>At the cache-miss rate, 500k input tokens cost <strong>$0.07</strong>. 20k output tokens cost <strong>$0.0056</strong>. Total: <strong>$0.0756</strong> before cache.</p>\n<p>At the cache-hit rate for everything except the first turn, the same session lands closer to <strong>$0.02–$0.04</strong>. That is for a milestone that would have taken me hours and a significant amount of attention.</p>\n<p>A heavy day, two milestones plus a refactor plus a documentation pass, has been landing somewhere in the <strong>$0.30–$0.90</strong> range. I have had monthly totals that look like rounding errors. I am not being clever about it. I just do not have to think about the meter.</p>\n<p>There is also <code>pi-deepseek-cache</code>, a small extension that pins the system prompt and tool definitions to keep the prefix-cache hot, and the developer reports a 95%+ cache hit rate once the loop stabilizes. I have not measured my own hit rate that carefully, but I have watched my daily bill drop when I started using it, and the savings are real.</p>\n<h2 id=\"the-closed-source-trap-i-walked-away-from\" tabindex=\"0\" data-toc-anchor=\"true\">The closed-source trap I walked away from</h2>\n<p>Let me be direct, because the marketing is designed to obscure this.</p>\n<p><strong>Cursor</strong> is a VS Code fork with proprietary extensions glued to someone else's APIs. You do not own the loop, you do not own the integrations, and you pay a per-month subscription that rises without your consent. The model is venture-funded price suppression: lose money on every seat, make it up on the locked-in base when the music stops. The usual playbook.</p>\n<p><strong>Claude Code</strong> is a genuinely good tool harness owned by Anthropic, which means it exists to sell Anthropic models. You are not the customer of the tool - you are the inventory. The pricing is opaque, the model access is gated, and the open-source contributions are decorative. When Anthropic raises Opus pricing next quarter - and it will - your loop cost triples and you have no alternative provider to switch to within the same harness. That is not a product. That is <strong>addiction by design</strong>: low-dose introductory offer, price escalates after the habit forms.</p>\n<p><strong>ChatGPT, GitHub Copilot, Codex</strong> - all proprietary. All trained on public data the companies would never let you train on. All designed to make you dependent on a closed API that can change terms, pricing, or access at any time with zero recourse. The open-weight models from DeepSeek, Mistral, and Llama are structurally more aligned with your interest as a developer: you can run them, fork them, audit them, and switch between them without asking for permission.</p>\n<p><strong>OpenCode</strong> and the emerging ecosystem of open-agent toolkits are moving in the right direction: tools that assume you want transparency, portability, and the freedom to change the model without changing the harness. That is the principle that matters. Not \"AI for everyone\" as a slogan, but <strong>\"AI you control\"</strong> as a property of the software.</p>\n<p>Pi is the only one of these that is open source at its core - MIT-licensed from day one, with a package registry where anyone can publish an extension without a review board or a commercial agreement. My <code>@pixu1980/pi-path-picker</code> is a small example, but the fact that I can write it, publish it, and use it without asking anyone is the entire difference between a platform and a prison.</p>\n<h3 id=\"the-extension-potential-is-genuinely-infinite\" tabindex=\"0\" data-toc-anchor=\"true\">The extension potential is genuinely infinite</h3>\n<p>The most surreal part of this setup is that the model itself can write extensions for the harness that runs it.</p>\n<p>DeepSeek V4 Flash reads the Pi extension documentation - a few pages of TypeScript interfaces and a <code>package.json</code> schema - and generates working extensions on the first try. I have done it. You describe what you want in plain English, the model reads the API docs from the repository, and it produces a complete extension: tool registration, prompt hooks, TUI integration, the whole thing. One prompt.</p>\n<p>That is the loop squared. You use an open-source agent to call an open-weight model, and the model extends the agent while you watch. The harness grows its own capabilities. There is no approval queue, no marketplace gatekeeper, no SDK version lock. You just describe, generate, publish, and use.</p>\n<p>It is incredible, it is satisfying, and it is the most empowering development workflow I have ever experienced. The ceiling is not set by a product manager's roadmap. The ceiling is set by what you can describe in a prompt.</p>\n<h2 id=\"the-honest-trade-offs\" tabindex=\"0\" data-toc-anchor=\"true\">The honest trade-offs</h2>\n<p>Nothing is free. Here is what I give up.</p>\n<ul>\n<li data-reveal=\"\"><strong>V4-Flash is still labeled Preview.</strong> The model is fast, cheap, and very good, but the weights are still in the preview line and the API name change in July 2026 means I will need to update configuration at some point.</li><li data-reveal=\"\"><strong>Pi does not babysit me.</strong> No permission popups, no dry-run confirmation, no safe-mode. If I let the agent run a destructive command, it runs. This is a feature for me, but it is a sharp edge for someone used to a more guided harness.</li><li data-reveal=\"\"><strong>No plan mode out of the box.</strong> I write my own prompt template when I want a planning step. Some people will hate this. I do not, because I find that hardcoded plan modes get in the way of the kind of small, fast, in-the-flow work I do most of the time.</li><li data-reveal=\"\"><strong>The model is Chinese-trained on a large multilingual corpus.</strong> I do not consider this a trade-off, but it is a fact. V4-Flash is excellent at English, very good at the rest of the languages I touch, and explicitly licensed for commercial use.</li><li data-reveal=\"\"><strong>Long-context reasoning still degrades past a point.</strong> The 1M context is a budget, not a free pass. The agent loop is at its best when the relevant files are well within the first 200k–400k tokens, and the rest of the context is supporting material.</li></ul>\n<p>None of these trade-offs are dealbreakers for me. They are constraints I work with.</p>\n<h2 id=\"why-this-is-the-new-default\" tabindex=\"0\" data-toc-anchor=\"true\">Why this is the new default</h2>\n<p>Five years ago, the choice of a coding agent was a tooling preference. Today, it is a budget question, a workflow question, and a philosophical question.</p>\n<p>Pi is a small, transparent, customizable harness that does not try to be my IDE, my project manager, or my safety net. It gives me a loop and gets out of the way. DeepSeek V4 Flash is a fast, open, properly sparse model that charges cents for what used to cost dollars. Together, they make the kind of agent-driven, multi-file, context-heavy work I do every day economically trivial.</p>\n<p>I am not saying Pi + V4-Flash is the right answer for every team. A regulated environment, a large enterprise, a security-sensitive codebase, or a team that needs deep IDE integration will make a different choice. But for an independent developer who ships a lot, who reads every diff, who does not need permission popups, and who used to flinch at the monthly AI bill, the answer is: this loop, this model, this cost.</p>\n<p>The model is becoming part of the abstraction layer. The agent harness is becoming part of the editor. The bill is becoming rounding error.</p>\n<p>That is the new default. I am not going back.</p>\n<h2 id=\"sources\" tabindex=\"0\" data-toc-anchor=\"true\">Sources</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://pi.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">pi.dev - official site</a></li><li data-reveal=\"\"><a href=\"https://github.com/earendil-works/pi?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">earendil-works/pi on GitHub</a></li><li data-reveal=\"\"><a href=\"https://api-docs.deepseek.com/news/news260424?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek V4 Preview release notes (April 24, 2026)</a></li><li data-reveal=\"\"><a href=\"https://api-docs.deepseek.com/quick_start/pricing?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek API - Models and pricing</a></li><li data-reveal=\"\"><a href=\"https://api-docs.deepseek.com/quick_start/agent_integrations/pi_mono?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek API - Integrate with Pi</a></li><li data-reveal=\"\"><a href=\"https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek V4 Flash on Hugging Face</a></li><li data-reveal=\"\"><a href=\"https://huggingface.co/blog/deepseekv4?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Hugging Face - DeepSeek V4 blog post</a></li><li data-reveal=\"\"><a href=\"https://hokai.io/hub/models/deepseek-v4-flash?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">HokAI - DeepSeek V4 Flash model profile</a></li><li data-reveal=\"\"><a href=\"https://codersera.com/blog/deepseek-v4-flash-deep-dive/amp/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Codersera - DeepSeek V4 Flash deep dive</a></li><li data-reveal=\"\"><a href=\"https://www.runlocalai.co/models/deepseek-v4-flash?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">RunLocalAI - DeepSeek V4 Flash model profile</a></li><li data-reveal=\"\"><a href=\"https://github.com/rohaquinlop/pi-deepseek-cache?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">rohaquinlop/pi-deepseek-cache on GitHub</a></li><li data-reveal=\"\"><a href=\"https://github.com/TheTrebor/pi-reasonix?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">thetrebor/pi-reasonix on GitHub</a></li><li data-reveal=\"\"><a href=\"https://pick-right.com/tools/pi/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pi Review - Pick Right</a></li><li data-reveal=\"\"><a href=\"https://shenxianpeng.github.io/en/posts/2026/pi-deepseek/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Xianpeng Shen - Writing an Article for Twenty-Four Cents: Pi + DeepSeek</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-20-pi-dev-deepseek-v4-flash-daily-loop.png",
      "date_published": "2026-06-20T00:00:00.000Z",
      "tags": [
        "ai",
        "workflow",
        "tooling",
        "making-of"
      ]
    }
  ]
}
