{
  "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-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": "I've been writing CSS since before border-radius was a thing, back when we used -moz-border-radius and prayed everything to work well.",
      "content_html": "<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<p>The way I see it, every CSS ruleset tells a story, and the story has a logical narrative arc:</p>\n<ol>\n<li><strong>Custom properties</strong> 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.</li><li><strong>Position</strong> comes 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.</li><li><strong>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>.</li><li><strong>Opacity &amp; visibility</strong> live here because they affect the box's presence without touching layout. They're the border between layout and paint.</li><li><strong>Box-model</strong> (width, padding, border, margin), the geometry. Inside-out: 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.</li><li><strong>Colors &amp; background</strong>, the paint. Text color, background, shadows, filters. These trigger repaints but not re-layouts (mostly).</li><li><strong>Typography</strong>, because 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>.</li><li><strong>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 they're also the most performance-sensitive, so having them in one block makes it easy to audit <code>will-change</code> usage.</li><li><strong>Helpers</strong>, <code>appearance</code>, <code>cursor</code>, <code>pointer-events</code>. Utility properties that don't fit anywhere else.</li><li><strong>Pseudo-elements</strong> (<code>&amp;::before</code>, <code>&amp;::after</code>), they're part of the element's visual tree, nested in.</li><li><strong>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.</li><li><strong>Media queries</strong> and <strong>children</strong>, scoping. <code>@media</code>, <code>&amp; span</code>, <code>&amp; &gt; *</code>. These are <em>new contexts</em> that restart the hierarchy. The comment says \"repeat css hierarchy here\" because each of these blocks is a fresh ruleset that follows the same order.</li></ol>\n<p>The order isn't arbitrary. It's the same order the browser processes properties: computed values → layout → paint → compositing.</p>\n<p>Every time I break this order, I introduce a subtle cognitive tax on the next reader, myself included.</p>\n<p>Every time I follow it, the ruleset reads like a coherent paragraph instead of a shopping list.</p>\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<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<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<p>So here's my challenge to you: 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>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>A greedy caching strategy can serve stale content to users who would prefer fresh.</li><li>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><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><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><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><strong>Background sync.</strong> The blog has nothing to sync. Readers do not post content.</li><li><strong>Push notifications.</strong> Reader-initiated subscriptions belong to the RSS layer.</li><li><strong>Periodic background sync.</strong> Same reason.</li><li><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><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><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><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><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>The image does not download until the browser predicts it is needed.</li><li>No JavaScript required.</li><li>The <code>decoding=\"async\"</code> hint tells the browser it can decode off the main thread.</li><li><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>Is the element above the fold and likely the LCP? → <code>loading=\"eager\"</code> + <code>fetchpriority=\"high\"</code>.</li><li>Is it an image or plain iframe below the fold? → <code>loading=\"lazy\"</code>.</li><li>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>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><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><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><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><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><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>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>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>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><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><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><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><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><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><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><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><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>Read <code>lang</code> and pick the lexer.</li><li>Tokenize the text content into <code>{ type, value }</code> tuples.</li><li>Render a sequence of <code>&lt;span class=\"tok-&lt;type&gt;\"&gt;</code> wrapping the tokens.</li><li>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><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><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><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><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><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><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><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>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>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>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><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><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><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><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><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>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>5k–20k tokens of output, which includes reasoning, diffs, and the final response.</li><li>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=\"what-i-stopped-using\" tabindex=\"0\" data-toc-anchor=\"true\">What I stopped using</h2>\n<p>I am not going to pretend the alternatives do not work. They do. They are just more expensive in ways that matter at the personal-tooling scale.</p>\n<ul>\n<li><strong>Cursor and the heavy IDE agents.</strong> Great for some workflows, expensive at the per-month subscription rate for the kind of agent-loop-heavy work I do, and they want to own the editor.</li><li><strong>Claude Code and the Anthropic-native loops.</strong> Excellent model, very good tool harness, but at Opus-class pricing, an output-heavy agent loop is roughly an order of magnitude more expensive than the same loop on V4-Flash, and for the work I do day-to-day the quality delta is not worth that multiplier.</li><li><strong>ChatGPT-style web UIs.</strong> Fine for one-shot questions. Useless for in-repo work, because the context does not survive a session and the tools are limited to copy-paste.</li></ul>\n<p>I am not saying these tools are bad. I am saying that for a developer who lives inside agent loops and who cares about the difference between a $0.30 day and a $5 day, the combination of Pi and DeepSeek V4 Flash is a structurally better default.</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><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><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><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><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><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><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><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><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><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><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><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><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><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><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><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><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><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><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><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"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-16-client-side-search-static-dataset.html",
      "url": "https://dout.dev/posts/2026-06-16-client-side-search-static-dataset.html",
      "title": "Client-Side Search on a Static Dataset (No Server, No Algolia, No Bullshit)",
      "summary": "The constraint (no backend, no excuses)",
      "content_html": "<h2 id=\"the-constraint-no-backend-no-excuses\" tabindex=\"0\" data-toc-anchor=\"true\">The constraint (no backend, no excuses)</h2>\n<p>A static blog has no backend. That is the whole fucking point. So search has to satisfy three rules:</p>\n<ol>\n<li>No server. No Algolia, no Lunr-as-a-service, no runtime dependency.</li><li>Small payload. A reader who never opens the search page should pay nothing.</li><li>Real results. Not just title contains-term. Tags, keywords, and series should rank too.</li></ol>\n<p>On dout.dev this shipped as a single <code>search.html</code> page that loads prebuilt JSON indexes from <code>/data/</code>, runs a light ranking function in the browser, and paginates the results. Total client code is under 200 lines.</p>\n<h2 id=\"the-indexes-are-built-at-the-cms-step\" tabindex=\"0\" data-toc-anchor=\"true\">The indexes are built at the CMS step</h2>\n<p>Every time the CMS runs, it emits four files under <code>src/data/</code>:</p>\n<ul>\n<li><code>posts.json</code> - one entry per published post: title, slug, date, description, tags, series, keywords extracted from the body;</li><li><code>tags.json</code> - one entry per tag: label, slug, count;</li><li><code>months.json</code> - one per month: <code>YYYY-MM</code>, count;</li><li><code>series.json</code> - one per series: label, slug, count.</li></ul>\n<p>A post entry looks like this:</p>\n<pre is=\"pix-highlighter\" data-lang=\"json\"><code>{\n  \"slug\": \"2026-05-19-html-native-template-engine\",\n  \"title\": \"An HTML-Native Template Engine Without eval()\",\n  \"date\": \"2026-05-19\",\n  \"description\": \"How the dout.dev template engine handles extends, blocks...\",\n  \"tags\": [\"architecture\", \"vanilla-js\", \"frontend\"],\n  \"series\": null,\n  \"keywords\": [\"template\", \"engine\", \"extends\", \"blocks\", \"eval\", \"sandbox\"]\n}</code></pre><p>The <code>keywords</code> array is computed from the body during the build: frequent, informative tokens the reader would plausibly search for. The ranker uses them later for query-term boosting.</p>\n<h2 id=\"the-query-layer-url-driven\" tabindex=\"0\" data-toc-anchor=\"true\">The query layer: URL-driven</h2>\n<p>The search page reads everything from the URL. That makes results shareable, bookmarkable, and back-button friendly.</p>\n<pre is=\"pix-highlighter\"><code>/search.html?q=template&amp;type=post&amp;type=tag&amp;page=2</code></pre><ul>\n<li><code>q</code> is the query term.</li><li><code>type</code> is repeatable and filters the result stream (post, tag, series, month).</li><li><code>page</code> is the pagination index, 1-based.</li></ul>\n<p>Any change to the form updates the URL via <code>history.pushState</code>. No framework. No state library.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function updateUrl({ q, types, page }) {\n  const params = new URLSearchParams();\n  if (q) params.set('q', q);\n  for (const t of types) params.append('type', t);\n  if (page &gt; 1) params.set('page', String(page));\n  const next = `${location.pathname}?${params.toString()}`;\n  history.pushState({}, '', next);\n}</code></pre><h2 id=\"the-ranker\" tabindex=\"0\" data-toc-anchor=\"true\">The ranker</h2>\n<p>The ranker is deliberately simple. A weighted sum of term hits against different fields, a small boost for exact keyword match, a tiny recency tilt.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function scorePost(post, tokens) {\n  const title = post.title.toLowerCase();\n  const desc = post.description.toLowerCase();\n  const tagString = post.tags.join(' ').toLowerCase();\n  const kwSet = new Set(post.keywords);\n\n  let score = 0;\n  for (const t of tokens) {\n    if (title.includes(t)) score += 5;\n    if (desc.includes(t)) score += 2;\n    if (tagString.includes(t)) score += 3;\n    if (kwSet.has(t)) score += 4;\n  }\n\n  // Light recency nudge: newer posts win ties\n  const days = (Date.now() - new Date(post.date).getTime()) / 86400000;\n  score += Math.max(0, 1 - days / 3650);\n\n  return score;\n}</code></pre><p>This is not Elasticsearch. It is a heuristic that works well when the corpus is a few dozen posts. I would not use it for ten thousand entries. For a blog, it is the right size of tool.</p>\n<h2 id=\"pagination-and-announcement\" tabindex=\"0\" data-toc-anchor=\"true\">Pagination and announcement</h2>\n<p>Results are paginated 10 at a time on the client. The pagination component matches the server-rendered archive pagination: <code>rel=\"prev\"</code> / <code>rel=\"next\"</code>, <code>aria-current=\"page\"</code> on the active page, ellipses that are not clickable.</p>\n<p>The results summary uses <code>aria-live=\"polite\"</code> so that screen readers hear the new result count when the query changes, without stealing focus.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;p class=\"results-summary\" aria-live=\"polite\" role=\"status\"&gt;7 results for \"template\". Page 1 of 1.&lt;/p&gt;</code></pre><p>That single attribute is the whole accessibility story for live-updating results.</p>\n<h2 id=\"what-about-fuzzy-matching-and-typos\" tabindex=\"0\" data-toc-anchor=\"true\">What about fuzzy matching and typos?</h2>\n<p>I punted. The corpus is small enough that users who mistype a word can correct it faster than the algorithm could guess. If the blog grows past a few hundred posts I would revisit - probably with a trigram index built at the same CMS step.</p>\n<p>The honest measurement is: on a corpus the size of dout.dev, exact-term search with field weighting is indistinguishable from fancier solutions in user satisfaction, and it costs a fraction of the bytes.</p>\n<h2 id=\"what-it-does-not-do-and-why-that-is-fine\" tabindex=\"0\" data-toc-anchor=\"true\">What it does not do, and why that is fine</h2>\n<ul>\n<li><strong>No search-as-you-type with network calls.</strong> Every keystroke scores against the in-memory dataset, which is already loaded.</li><li><strong>No analytics on queries.</strong> The site analytics are page hits only (no cookies), and search is intentionally out of scope.</li><li><strong>No autocomplete.</strong> It could be added with the same dataset and twenty more lines. I did not need it.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A static site can have a good search without a service. Build the indexes when you build the site. Load them on the search page only. Write a ranking function that matches your corpus. Drive it from the URL. That is the whole story, and the client code fits on a page.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">URLSearchParams - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/History_API?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">History API - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Live regions - MDN</a></li><li><a href=\"https://lunrjs.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Lunr</a> - if you outgrow the handwritten ranker</li><li><a href=\"https://pagefind.app/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pagefind</a> - a solid static-search option at the \"many thousands of pages\" scale</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-16-client-side-search-static-dataset.png",
      "date_published": "2026-06-16T00:00:00.000Z",
      "tags": [
        "vanilla-js",
        "search",
        "architecture",
        "static-site"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-13-the-end-of-borrowed-abstractions.html",
      "url": "https://dout.dev/posts/2026-06-13-the-end-of-borrowed-abstractions.html",
      "title": "The End of Borrowed Abstractions",
      "summary": "The reflex that shaped software",
      "content_html": "<h2 id=\"the-reflex-that-shaped-software\" tabindex=\"0\" data-toc-anchor=\"true\">The reflex that shaped software</h2>\n<p>For the last twenty years, a large part of software development has been guided by a very simple reflex: when the platform feels too low-level, we install an abstraction.</p>\n<p>At first, this was not only reasonable, it was necessary. Platforms were incomplete, browser behavior was inconsistent, standard libraries were often limited, and teams needed shared mental models to ship products at a human pace. Frameworks and libraries gave us vocabulary, structure, conventions, and a way to avoid solving the same problems over and over again.</p>\n<p>The problem is that, over time, this useful reflex became almost automatic. Need UI composition? Install a framework. Need routing, state management, validation, forms, styling, animation, authentication glue, build orchestration, dependency injection, data fetching, queues, command handling, or configuration management? Install something.</p>\n<p>Eventually, the product starts becoming only one part of the system. The other part is a carefully negotiated dependency graph, where every new feature has to pass through somebody else's assumptions, release cycle, architecture, migration path, documentation quality, security posture, and long-term maintenance choices.</p>\n<p>This is not a frontend problem. It is not a JavaScript problem. It is not a React problem. It is not even a framework problem. It is a software problem.</p>\n<p>Every ecosystem has its own version of this story. JavaScript has frontend frameworks and npm. Python has web frameworks, ML stacks, data tools, and long chains of packages. Java has decades of enterprise abstractions. .NET has its own layered ecosystems. PHP, Ruby, Go, Rust, Swift, Kotlin, and C# all have communities that build towers of convenience on top of native language and runtime features.</p>\n<p>Again, this did not happen because developers were careless. It happened because abstractions are one of the main tools we have for dealing with complexity. Software is abstraction. The real question is not whether we should use abstractions, but where they should live, who should own them, how much of them should run in production, and whether borrowing them is still the best economic choice.</p>\n<p>That question is becoming more urgent because the ground is moving. The next major abstraction layer may not be another framework. <strong>The next major abstraction layer may be the model.</strong></p>\n<h2 id=\"frameworks-were-built-for-humans\" tabindex=\"0\" data-toc-anchor=\"true\">Frameworks were built for humans</h2>\n<p>Frameworks exist because humans need constraints. We need names, conventions, file structures, lifecycle models, shared patterns, and a limited number of concepts to keep in our heads at the same time. A framework is not just code. It is a cognitive compression format.</p>\n<p>It tells us how to think about the system. Do not think about everything at once. Think in components, routes, controllers, models, hooks, stores, services, middleware, providers, modules, or pipelines. This is extremely useful when human developers are the only abstraction engine in the loop, because humans need stable shapes to coordinate work, communicate intent, and reduce ambiguity.</p>\n<p>But LLMs do not need abstractions in exactly the same way.</p>\n<p>A model does not care whether a pattern is called a hook, a provider, a composable, a bean, a service, a trait, a reducer, a module, or a pipeline. Those names still matter to us, because they help teams communicate and maintain the system, but they are not the deepest source of quality for AI-assisted software.</p>\n<p>What a model needs is context. It needs constraints. It needs examples, tests, schemas, documentation, review rules, runtime signals, tool access, and clear success criteria. In other words, the most important abstraction for an AI-assisted codebase is not always the framework used by the application. It is the operational environment around the codebase.</p>\n<p>That moves the center of gravity.</p>\n<p>Instead of placing all best practices inside runtime abstractions, we can start placing more of them inside instructions, tests, static analysis, design tokens, architecture rules, accessibility checks, security policies, and project-specific agent skills. The abstraction is still there, but it is no longer necessarily something we ship to users or execute on every request.</p>\n<p>This is a very different kind of software architecture.</p>\n<h2 id=\"the-new-abstraction-layer-lives-around-the-code\" tabindex=\"0\" data-toc-anchor=\"true\">The new abstraction layer lives around the code</h2>\n<p>The next abstraction layer will increasingly be made of things that surround the code rather than things that always run inside it. MCP servers, agent skills, local LLMs, cloud LLMs, project-specific prompts, typed schemas, linters, formatters, test suites, static analysis, design tokens, accessibility rules, security policies, architecture decision records, code review agents, and documentation indexed as working memory all point in the same direction.</p>\n<p>They create an environment where code can be generated, constrained, checked, refactored, and reviewed with more project awareness than before.</p>\n<p>This does not mean we stop using abstractions. That would make no sense. It means we can stop shipping so many borrowed abstractions to production when their main value is no longer runtime behavior, but repetition, convention, scaffolding, and guidance.</p>\n<p>A model can generate repetitive implementation. A linter can enforce a project rule. A type system can constrain a data shape. A test can verify behavior. An agent can apply the same refactor across many files. A design system can become tokens, fixtures, examples, and visual checks. A security rule can become a gate. An accessibility expectation can become part of the definition of done.</p>\n<p>At that point, many dependencies start to look different. Some still provide deep value and should absolutely be used. Others begin to look less like leverage and more like inherited risk.</p>\n<p>The question changes from \"which library should we install?\" to \"should this be a dependency at all?\"</p>\n<h2 id=\"the-dependency-tax\" tabindex=\"0\" data-toc-anchor=\"true\">The dependency tax</h2>\n<p>Dependencies are not free. They increase bundle size, install time, build complexity, runtime surface, security exposure, version drift, documentation debt, maintenance overhead, and future migration cost.</p>\n<p>Those are the obvious costs. The deeper cost is architectural.</p>\n<p>Every dependency imports somebody else's decisions into your product. It brings assumptions about how problems should be modeled, how APIs should behave, how edge cases should be handled, and how the future should evolve. It becomes part of your onboarding path, hiring profile, debugging surface, security model, review process, and long-term roadmap.</p>\n<p>The software industry loves reuse, and for good reason. Reuse can be powerful. But reuse is also coupling, and coupling has to be paid for.</p>\n<p>The npm ecosystem makes this easy to see because it is large, fast-moving, and deeply interconnected, but the same dynamic exists in every ecosystem. Sonatype reported more than 454,600 new malicious packages identified throughout 2025 across major open-source ecosystems, bringing the cumulative total of known and blocked malware above 1.233 million packages. Research on npm supply-chain weak links also shows how package metadata, install scripts, expired maintainer domains, inactive maintainers, and account takeover opportunities can expose thousands of downstream packages.</p>\n<p>This does not mean \"never use dependencies\". That would be a childish conclusion. It means dependencies must become deliberate architectural choices again. They should earn their place through real complexity, real specialization, real maintenance value, or real domain expertise.</p>\n<p>They should not be the default answer to every small discomfort.</p>\n<h2 id=\"ai-changes-the-economics-of-code\" tabindex=\"0\" data-toc-anchor=\"true\">AI changes the economics of code</h2>\n<p>For a long time, dependency-heavy development made economic sense. Writing everything in-house was expensive. Maintaining internal abstractions was expensive. Generating boilerplate was boring. Refactoring repeated patterns was slow. Documenting everything was painful. Testing every variation took time.</p>\n<p>So we outsourced complexity to frameworks and libraries. We accepted the dependency tax because the alternative was often slower, more expensive, and more fragile.</p>\n<p>AI changes that equation, not because it magically produces perfect software, but because it changes the cost of producing certain categories of code.</p>\n<p>Stack Overflow's 2025 Developer Survey reports that 84% of respondents are using or planning to use AI tools, and 51% of professional developers use them daily. GitHub reported that nearly 80% of new developers on GitHub used Copilot within their first week in 2025. Controlled research on GitHub Copilot found that developers completed a JavaScript task 55.8% faster when using the tool.</p>\n<p>The picture is not universally simple. Other studies are more nuanced, and they should be. AI coding tools are more useful in some contexts than others, and generated code still requires strong human review, especially in complex systems, proprietary codebases, security-sensitive areas, and large multi-file changes.</p>\n<p>But the important point is not that AI writes perfect code. It does not.</p>\n<p>The important point is that AI makes boilerplate, repetitive implementation, documentation, test scaffolding, migration work, refactoring, API adaptation, pattern replication, simple feature assembly, and codebase navigation cheaper than they used to be.</p>\n<p>Those are exactly the categories of work that made teams install many libraries in the first place.</p>\n<p>If a model can generate a project-specific implementation that follows your coding rules, passes your tests, respects your security constraints, uses your design tokens, and avoids unnecessary runtime dependencies, then the economics of abstraction change.</p>\n<p>At that point, the question is no longer only \"which package solves this?\"</p>\n<p>The better question becomes \"why should this be a package?\"</p>\n<h2 id=\"standards-will-matter-more-not-less\" tabindex=\"0\" data-toc-anchor=\"true\">Standards will matter more, not less</h2>\n<p>One of the common misunderstandings about AI-generated software is that it will make standards less important. I think the opposite is true.</p>\n<p>AI will make standards more valuable because models work better when the target is stable, documented, widely used, and well represented. Language standards, platform APIs, protocol specifications, accessibility rules, security practices, and explicit architectural constraints are model-friendly.</p>\n<p>A framework is a moving target controlled by a smaller community. A standard is a wider agreement.</p>\n<p>On the web platform, we can already see this shift. Modern CSS is absorbing capabilities that once required preprocessors, JavaScript helpers, or framework-level conventions. Typed attr() lets CSS read attributes as typed values. CSS if() brings conditional logic into values. CSS custom functions aim to make reusable CSS logic native to the language.</p>\n<p>When the platform learns these primitives, teams can use them directly only if their abstraction layers do not get in the way. Otherwise, the browser may already support the capability, but the product still has to wait for a framework, a library, a wrapper, a plugin, or a design system abstraction to catch up, expose it, document it, and stop fighting it.</p>\n<p>That is one ecosystem, but the principle is broader. When platforms become more expressive, and AI becomes better at producing code against stable standards, the value of non-standard abstraction decreases.</p>\n<p>The winning move is not to reject every framework. The winning move is to move as much product logic as possible toward stable, inspectable, standard primitives.</p>\n<p>Use the language. Use the runtime. Use the platform. Use the framework when it still buys something real.</p>\n<h2 id=\"best-practices-become-executable-governance\" tabindex=\"0\" data-toc-anchor=\"true\">Best practices become executable governance</h2>\n<p>Many teams use frameworks as delivery mechanisms for best practices. The framework tells them how to organize files, fetch data, render pages, place state, compose UI, and decide which patterns are acceptable.</p>\n<p>In an AI-assisted workflow, many of those rules can move into configuration, automation, and project context.</p>\n<p>Coding standards can become lint rules. Architecture decisions can become generation constraints. Design guidelines can become tokens and visual tests. Accessibility expectations can become automated checks. Security policies can become static analysis and dependency gates. Performance budgets can become CI failures. Reusable patterns can become agent skills. Project knowledge can become context the model can actually use.</p>\n<p>This is a major change because best practices stop being trapped inside framework conventions and become executable governance.</p>\n<p>A convention helps when developers remember it. A rule enforces it. A test verifies it. An agent applies it repeatedly. A model generates code from it.</p>\n<p>That is the future shape of software abstraction. Not \"everyone must memorize the framework\", but \"the system knows the rules and keeps applying them\".</p>\n<h2 id=\"the-end-of-framework-identity\" tabindex=\"0\" data-toc-anchor=\"true\">The end of framework identity</h2>\n<p>The industry has spent years treating frameworks as identity. We say \"I am a React developer\", \"I am a Laravel developer\", \"I am a Rails developer\", \"I am a Spring developer\", \"I am a Django developer\", \"I am a Next developer\".</p>\n<p>This made sense when frameworks were the main interface between developers and complexity. But in an AI-native development environment, that identity becomes too small.</p>\n<p>The valuable skill will not be loyalty to a framework. The valuable skill will be understanding systems: language, runtime, platform, accessibility, security, performance, data, product constraints, and the way AI-generated work must be instructed, constrained, verified, and reviewed.</p>\n<p>The senior developer of the next era is not the person who knows the most framework APIs by memory. It is the person who can design the rules of the system so that humans and models can safely produce good software together.</p>\n<p>That requires more engineering, not less.</p>\n<h2 id=\"this-is-not-no-code\" tabindex=\"0\" data-toc-anchor=\"true\">This is not no-code</h2>\n<p>This future is not no-code. No-code pretends complexity can disappear, and complexity does not disappear. It moves.</p>\n<p>What changes is the layer where engineering work happens. Less time wiring borrowed abstractions, more time defining contracts. Less time adapting to framework churn, more time designing durable systems. Less time installing packages for trivial problems, more time making sure the generated solution is correct, accessible, secure, observable, and maintainable.</p>\n<p>AI does not remove the need for senior engineers. It increases the need for them.</p>\n<p>Because when code becomes cheaper to produce, judgment becomes more valuable.</p>\n<h2 id=\"a-more-vanilla-world\" tabindex=\"0\" data-toc-anchor=\"true\">A more vanilla world</h2>\n<p>\"Vanilla\" does not mean naive. It does not mean writing everything from scratch forever. It does not mean refusing tools, frameworks, libraries, or ecosystems.</p>\n<p>It means preferring the native language, native runtime, and platform standards unless a dependency clearly earns its place.</p>\n<p>A more vanilla world is not a world without abstraction. It is a world where abstraction is generated, governed, inspected, and owned. A world where dependencies are exceptions, not defaults. A world where libraries are chosen because they provide deep, hard, specialized value, not because nobody wanted to write twenty lines of code.</p>\n<p>It is a world where frameworks are useful tools, not architectural religions. A world where the model becomes part of the abstraction layer, but the output remains understandable, standard, testable, and close to the platform.</p>\n<p>This will not happen all at once. There will still be frameworks, libraries, ecosystems, and very good reasons to depend on external code. But the default is going to change.</p>\n<p>The age of installing an abstraction for every small discomfort is ending.</p>\n<p>The next era belongs to teams that can combine human judgment, platform standards, automated governance, and AI-generated implementation. Not because frameworks suddenly became useless, but because many of the reasons we needed them are being absorbed by something else.</p>\n<p>The model is becoming part of the abstraction. The platform is getting stronger. The dependency graph is becoming a liability.</p>\n<p>And software teams are about to rediscover a very old idea: the best code is not the code you borrowed. It is the code you understand, control, verify, and can afford to change.</p>\n<h2 id=\"sources\" tabindex=\"0\" data-toc-anchor=\"true\">Sources</h2>\n<ul>\n<li><a href=\"https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sonatype, \"The Evolving Software Supply Chain Attack Surface\"</a></li><li><a href=\"https://arxiv.org/abs/2112.10165?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Zahan et al., \"What are Weak Links in the npm Supply Chain?\"</a></li><li><a href=\"https://survey.stackoverflow.co/2025/ai?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Stack Overflow Developer Survey 2025, AI</a></li><li><a href=\"https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">GitHub Octoverse 2025, \"A new developer joins GitHub every second as AI leads TypeScript to #1\"</a></li><li><a href=\"https://arxiv.org/abs/2302.06590?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Peng et al., \"The Impact of AI on Developer Productivity: Evidence from GitHub Copilot\"</a></li><li><a href=\"https://arxiv.org/abs/2406.17910?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pandey et al., \"Transforming Software Development: Evaluating the Efficiency and Challenges of GitHub Copilot in Real-World Projects\"</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/attr?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN Web Docs, \"attr() CSS function\"</a></li><li><a href=\"https://developer.chrome.com/blog/if-article?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Chrome Developers, \"CSS conditionals with the new if() function\"</a></li><li><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></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-13-the-end-of-borrowed-abstractions.png",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "architecture",
        "ai",
        "vanilla-js",
        "frontend",
        "security"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-09-why-display-preferences-popover-exists.html",
      "url": "https://dout.dev/posts/2026-06-09-why-display-preferences-popover-exists.html",
      "title": "Why DisplayPreferencesPopover Exists (Accessibility Is Not a Checklist, It's User Preferences)",
      "summary": "Accessibility is not one feature (it's ALL of them)",
      "content_html": "<h2 id=\"accessibility-is-not-one-feature-it-s-all-of-them\" tabindex=\"0\" data-toc-anchor=\"true\">Accessibility is not one feature (it's ALL of them)</h2>\n<p>Most teams treat accessibility as a checklist at the end of a sprint. That approach misses the real problem. Accessibility is the baseline behavior of the interface under real user constraints.</p>\n<p>Those constraints are not abstract. They are concrete:</p>\n<ul>\n<li>small screens and zoomed layouts;</li><li>reduced motion preferences;</li><li>higher contrast needs;</li><li>transparent surfaces that lower readability;</li><li>font sizes and font families that make text easier to parse.</li></ul>\n<p><strong>If the UI ignores those settings, it is not accessible</strong>, even when every button has a good label.</p>\n<h2 id=\"responsive-design-is-accessibility\" tabindex=\"0\" data-toc-anchor=\"true\">Responsive design is accessibility</h2>\n<p>The popover on dout.dev exposes settings that the browser already knows about: motion, contrast, color scheme, font size. It does not invent new ones. The settings are mapped directly to CSS custom properties that flip at the root level.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Accessibility is not a feature you bolt on. It is the system's respect for user preferences. A popover that exposes those preferences is not a nice-to-have; it is the UI admitting that the browser cannot detect every user need, and giving the user control.</p>\n<p>Design for preferences first. Add ARIA second. That order matters.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-09-why-display-preferences-popover-exists.png",
      "date_published": "2026-06-09T00:00:00.000Z",
      "tags": [
        "accessibility",
        "frontend",
        "responsive-design"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-06-how-pixhighlighter-is-built.html",
      "url": "https://dout.dev/posts/2026-06-06-how-pixhighlighter-is-built.html",
      "title": "How PixHighlighter Is Built (Or: I Wrote a Syntax Highlighter Because Prism Was Too Bloaty)",
      "summary": "The contract stays plain (because HTML should be readable)",
      "content_html": "<h2 id=\"the-contract-stays-plain-because-html-should-be-readable\" tabindex=\"0\" data-toc-anchor=\"true\">The contract stays plain (because HTML should be readable)</h2>\n<p><code>PixHighlighter</code> begins with the smallest useful shape:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;pre is=\"pix-highlighter\" data-lang=\"js\"&gt;&lt;code&gt;const answer = 42;&lt;/code&gt;&lt;/pre&gt;</code></pre><p>That is still a real <code>&lt;pre&gt;</code> element with a real <code>&lt;code&gt;</code> child. The source is readable BEFORE JavaScript runs, copyable as text, and understandable to assistive technology. The component does not need a custom shadow tree to make code look like code.</p>\n<p><strong>The important decision is what it refuses to do by default:</strong> the primary path does NOT wrap every token in markup. The code block is not a pile of span wrappers. It is ONE text node that can be painted by the browser. This is the kind of detail that separates \"it works\" from \"it works well.\"</p>\n<h2 id=\"lexers-describe-positions-not-markup\" tabindex=\"0\" data-toc-anchor=\"true\">Lexers describe positions (not markup)</h2>\n<p>Each lexer under <code>src/scripts/components/PixHighlighter/Lexers/</code> walks the source and emits ranges:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>{ type: 'kw', start: 0, end: 5 }</code></pre><p>That object says \"the characters from 0 to 5 are a keyword.\" It does NOT say \"insert a span here.\" Parsing and rendering stay separate. The same token stream can power the modern renderer, the fallback renderer, tests, and any future diagnostics without changing the source model.</p>\n<p>Language aliases are normalized before lookup. <code>javascript</code> becomes <code>js</code>, <code>typescript</code> becomes <code>ts</code>, <code>shell</code> and <code>zsh</code> become <code>bash</code>. Content authors get a forgiving interface; the renderer gets ONE clean language key. Consistency is a feature.</p>\n<h2 id=\"css-custom-highlight-api-does-the-painting-this-is-the-clever-part\" tabindex=\"0\" data-toc-anchor=\"true\">CSS Custom Highlight API does the painting (this is the clever part)</h2>\n<p>The best version of <code>PixHighlighter</code> uses the CSS Custom Highlight API. When <code>CSS.highlights</code> and <code>Highlight</code> exist, the component creates DOM <code>Range</code> objects for each token and stores them in named highlight registries:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const highlight = new Highlight(range);\nCSS.highlights.set('pix-kw', highlight);</code></pre><p>CSS then styles those names directly:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>::highlight(pix-kw) {\n  color: var(--pix-highlighter--kw);\n}</code></pre><p>That is the whole goddamn trick. The browser paints a range of text WITHOUT changing the text into markup. Selection stays clean. Copy stays clean. The DOM stays quiet. Themes remain normal CSS variables instead of a second token system hidden inside generated spans.</p>\n<h2 id=\"the-fallback-is-deliberately-boring-because-boring-is-reliable\" tabindex=\"0\" data-toc-anchor=\"true\">The fallback is deliberately boring (because boring is reliable)</h2>\n<p>The rule is simple: fallback spans only when the Highlight API is unavailable. In that case, the component maps the same token ranges to conservative markup:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;span data-token=\"kw\"&gt;const&lt;/span&gt;</code></pre><p>The fallback is NOT a different highlighter. It is the same lexer output rendered into older browser primitives. The CSS already has matching rules for <code>[data-token='kw']</code>, <code>[data-token='str']</code>, and the rest of the token types, so the visual result remains close without forcing modern browsers to carry extra DOM.</p>\n<h2 id=\"styles-are-shared-once-because-performance-matters\" tabindex=\"0\" data-toc-anchor=\"true\">Styles are shared once (because performance matters)</h2>\n<p>Ten code blocks still get ONE style payload. The component can appear throughout an article without multiplying its CSS. No duplication. No waste.</p>\n<h2 id=\"themes-are-page-state-not-component-state\" tabindex=\"0\" data-toc-anchor=\"true\">Themes are page state (not component state)</h2>\n<p>Every instance renders a compact toolbar with copy and theme controls. The picker appears per block, but the selected theme is page-wide state. That avoids a page where one snippet is using one palette and the next snippet is using another because of local component state. Code theme is a reading preference, so the page owns it.</p>\n<h2 id=\"copy-reads-the-source-not-the-highlights\" tabindex=\"0\" data-toc-anchor=\"true\">Copy reads the source (not the highlights)</h2>\n<p>The copy button reads <code>code.textContent</code>. It does NOT inspect highlight ranges, fallback spans, toolbar state, or theme state. Highlighting is visual; the source text is the contract.</p>\n<h2 id=\"why-this-fits-dout-dev-the-architecture-summary\" tabindex=\"0\" data-toc-anchor=\"true\">Why this fits dout.dev (the architecture summary)</h2>\n<p><code>PixHighlighter</code> is built for a static editorial site: short examples, no frontend framework, no runtime highlighter dependency, and a design system that already owns typography, color, and motion.</p>\n<p>The architecture works because each layer has ONE job:</p>\n<ol>\n<li>Markdown emits semantic code blocks.</li><li>Lexers produce token ranges.</li><li>The CSS Custom Highlight API paints those ranges without spans.</li><li>Fallback markup covers browsers without highlight support.</li><li>Toolbar actions enhance the block without becoming the content.</li></ol>\n<p>That is the pattern I want here: ordinary HTML first, modern browser APIs where they remove markup, and a fallback that preserves the same source text instead of inventing a second content model.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-06-how-pixhighlighter-is-built.png",
      "date_published": "2026-06-06T00:00:00.000Z",
      "tags": [
        "vanilla-js",
        "frontend",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-02-wcag-22-aa-without-aria-spam.html",
      "url": "https://dout.dev/posts/2026-06-02-wcag-22-aa-without-aria-spam.html",
      "title": "WCAG 2.2 AA Without ARIA-Spam (Landmarks, Heading Order, Skip-Links — That's 80% of It)",
      "summary": "The unpopular opinion (brace yourself)",
      "content_html": "<h2 id=\"the-unpopular-opinion-brace-yourself\" tabindex=\"0\" data-toc-anchor=\"true\">The unpopular opinion (brace yourself)</h2>\n<p><em>Most accessibility failures I see in production are not missing features.</em> They are HTML that was never semantic, covered in ARIA attributes that were supposed to fix the damage. That approach is a tax forever. ARIA is a powerful tool and also a trap: the first rule of ARIA is to NOT use it if a native element would do the job.</p>\n<p>For dout.dev I made a rule for myself. <strong>Every page starts as semantic HTML. ARIA only shows up when there is no native alternative.</strong> That rule covered most of the WCAG 2.2 AA checklist before I wrote a single <code>aria-*</code> attribute.</p>\n<h2 id=\"landmarks-use-the-elements-not-the-roles\" tabindex=\"0\" data-toc-anchor=\"true\">Landmarks: use the elements, not the roles</h2>\n<p>HTML5 already gives you landmarks. A screen reader or a \"Rotor\" in VoiceOver reads them as navigation regions. You do NOT need to annotate them.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;body&gt;\n  &lt;a class=\"skip-link\" href=\"#main\"&gt;Skip to content&lt;/a&gt;\n  &lt;header&gt;\n    &lt;nav aria-label=\"Primary\"&gt;\n      &lt;!-- navigation links --&gt;\n    &lt;/nav&gt;\n  &lt;/header&gt;\n  &lt;main id=\"main\"&gt;\n    &lt;!-- content --&gt;\n  &lt;/main&gt;\n  &lt;footer&gt;\n    &lt;!-- footer content --&gt;\n  &lt;/footer&gt;\n&lt;/body&gt;</code></pre><p><code>&lt;header&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;footer&gt;</code> are already landmarks. The only <code>role</code> I add is on the <code>&lt;nav&gt;</code> when there are multiple nav elements, and even then I use <code>aria-label=\"Primary\"</code> to disambiguate.</p>\n<h2 id=\"heading-order-it-s-not-that-hard\" tabindex=\"0\" data-toc-anchor=\"true\">Heading order (it's not that hard)</h2>\n<p>A screen reader user navigates by headings. If your heading order goes h1 → h3 → h2, you are making blind people work harder for no reason. The rule is simple: never skip levels. h1 → h2 → h3, not h1 → h4.</p>\n<p>Each page has exactly one <code>&lt;h1&gt;</code>. Sections within the page start at <code>&lt;h2&gt;</code>. Subsections at <code>&lt;h3&gt;</code>. If you need four levels of nesting in a blog post, your information architecture has bigger problems.</p>\n<h2 id=\"skip-links-the-5-minute-win\" tabindex=\"0\" data-toc-anchor=\"true\">Skip-links (the 5-minute win)</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;a class=\"skip-link\" href=\"#main\"&gt;Skip to content&lt;/a&gt;</code></pre><p>That is the single highest-impact accessibility fix for keyboard users. One link, visible on focus, that skips the navigation and lands the user in the main content. It takes five minutes to add and saves every keyboard user from tabbing through 47 navigation links.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>WCAG 2.2 AA is not a checklist of ARIA attributes you need to add. It is a checklist of semantic HTML patterns you need to follow. Landmarks, heading order, skip-links — these three things cover more ground than any ARIA-based remediation.</p>\n<p>Write good HTML first. Add ARIA only when the native element genuinely does not cover the case. That is the entire accessibility strategy for dout.dev, and it works.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-02-wcag-22-aa-without-aria-spam.png",
      "date_published": "2026-06-02T00:00:00.000Z",
      "tags": [
        "accessibility",
        "html",
        "frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-30-modern-css-is-enough.html",
      "url": "https://dout.dev/posts/2026-05-30-modern-css-is-enough.html",
      "title": "Modern CSS Is Enough (Container Queries, Nesting, `:has()` — No Framework Required)",
      "summary": "The short version (for the impatient)",
      "content_html": "<h2 id=\"the-short-version-for-the-impatient\" tabindex=\"0\" data-toc-anchor=\"true\">The short version (for the impatient)</h2>\n<p>For about ten years, serious frontend work leaned on tooling that papered over missing CSS features: Sass for nesting, Styled Components for dynamic theming, BEM conventions for scoping, utility frameworks for constraint. Most of those reasons quietly disappeared in the last two browser cycles. Modern CSS has container queries, native nesting, <code>:has()</code>, cascade layers, logical properties, <code>color-mix()</code>, <code>clamp()</code>, and more. On dout.dev I ship ALL of these, and the CSS is shorter than it would have been five years ago.</p>\n<p>This post is NOT \"CSS is cool now.\" It is <strong>a walk through specific modern features that let me delete a preprocessor and a handful of conventions</strong>. If you're still using Sass in 2026, we need to talk.</p>\n<h2 id=\"container-queries-instead-of-media-queries\" tabindex=\"0\" data-toc-anchor=\"true\">Container queries instead of media queries</h2>\n<p>Media queries size components against the viewport. That is the WRONG reference frame for components that can appear in a full-width article or a narrow sidebar. Container queries fix it.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.post-card {\n  container-type: inline-size;\n  container-name: card;\n}\n\n@container card (min-width: 400px) {\n  .post-card__title { font-size: var(--text-xl); }\n}</code></pre><p>The component queries its own parent, not the viewport. That is the correct behavior for a reusable component. Media queries are for layout; container queries are for components.</p>\n<h2 id=\"the-features-i-actually-use\" tabindex=\"0\" data-toc-anchor=\"true\">The features I actually use</h2>\n<p><strong>Native nesting</strong> — <code>header { h1 { ... } }</code> works in every modern browser. No Sass compilation step. No <code>&amp;</code> prefix needed for simple selectors.</p>\n<p><strong><code>:has()</code> selector</strong> — the \"parent selector\" CSS always needed. <code>post-card:has(&gt; img)</code> to style cards differently when they have cover images. No JavaScript, no extra class.</p>\n<p><strong>Cascade layers</strong> — <code>@layer base, components, utilities</code> lets you control specificity order explicitly instead of fighting it with <code>!important</code>.</p>\n<p><strong><code>color-mix()</code></strong> — <code>color-mix(in srgb, var(--color-accent), white 20%)</code> gives you tinted variants without maintaining color scales.</p>\n<p><strong><code>clamp()</code></strong> — <code>font-size: clamp(1rem, 2.5vw, 1.5rem)</code> gives you fluid typography without breakpoints.</p>\n<h2 id=\"what-i-removed\" tabindex=\"0\" data-toc-anchor=\"true\">What I removed</h2>\n<ul>\n<li><strong>Sass.</strong> Nesting is native. Variables are custom properties. <code>darken()</code> is <code>color-mix()</code>.</li><li><strong>BEM.</strong> Cascade layers and <code>@scope</code> reduce the need for naming conventions.</li><li><strong>PostCSS plugins.</strong> Most of what they did is now native or unnecessary.</li><li><strong>A CSS reset.</strong> <code>box-sizing: border-box</code> on everything and <code>margin: 0</code> on body covers 90% of the cases.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Modern CSS is not a toy. It is a production-ready styling system that has absorbed most of the reasons people reached for preprocessors and frameworks. If you are still reaching for Sass or a CSS framework out of habit, take a weekend to audit what you actually need. The answer might be \"less than you think.\"</p>\n<p>Your bundle will thank you. Your users will thank you. Your future self maintaining this in 2030 will thank you.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-30-modern-css-is-enough.png",
      "date_published": "2026-05-30T00:00:00.000Z",
      "tags": [
        "css",
        "vanilla-js",
        "frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-26-vibe-coding-with-rigor.html",
      "url": "https://dout.dev/posts/2026-05-26-vibe-coding-with-rigor.html",
      "title": "Vibe Coding With Rigor (Or: How to Use AI Without Your Codebase Turning Into a Dumpster Fire)",
      "summary": "The term I am reclaiming (because \"vibe coding\" got a bad rap)",
      "content_html": "<h2 id=\"the-term-i-am-reclaiming-because-vibe-coding-got-a-bad-rap\" tabindex=\"0\" data-toc-anchor=\"true\">The term I am reclaiming (because \"vibe coding\" got a bad rap)</h2>\n<p>\"Vibe coding\" has become shorthand for \"let the AI write whatever it wants and see what happens.\" That is NOT what I did on dout.dev, and it is not what the term should mean if it is going to be useful. The version I work with looks more like this:</p>\n<blockquote>\n<p>Vibe coding is <strong>driving an AI copilot hard with clear intent, small units of work, and strict review, while keeping the architectural decisions in your own head.</strong></p>\n</blockquote>\n<p><strong>The vibe is the speed. The rigor is the review.</strong> This post is the workflow that made 19 milestones shippable without the repo turning into a science fair.</p>\n<h2 id=\"the-unit-of-work-one-outcome-one-commit\" tabindex=\"0\" data-toc-anchor=\"true\">The unit of work (one outcome, one commit)</h2>\n<p>Every session had one outcome. \"Wire up the RSS feed.\" \"Add the skip-link.\" \"Extract the pagination component.\" NOT \"improve the site.\" NOT \"make it more accessible.\" The outcome fit in one commit.</p>\n<p>That discipline mattered more than the prompt engineering. When a session has a scoped outcome, the copilot's proposals can be evaluated against a clear question: did this produce the outcome, and is the diff small? When a session is open-ended, every proposal feels plausible, and you accept changes you later regret.</p>\n<h2 id=\"the-prompt-pattern-i-actually-used-it-s-not-that-deep\" tabindex=\"0\" data-toc-anchor=\"true\">The prompt pattern I actually used (it's not that deep)</h2>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code>Context: we are in a repo at &lt;path&gt;. The build system is &lt;x&gt;.\nTask: &lt;one specific thing&gt;. \nConstraints: &lt;rules the output must follow&gt;.\nAcceptance criteria: \n1. &lt;checkable thing&gt;\n2. &lt;checkable thing&gt;</code></pre><p>That's it. No system prompts. No \"act as a senior engineer\" preamble. The copilot does not need a persona; it needs context, a task, and constraints. The acceptance criteria are the most important part — they turn the output from \"looks right\" to \"provably right.\"</p>\n<h2 id=\"the-review-loop-read-every-diff\" tabindex=\"0\" data-toc-anchor=\"true\">The review loop (read every diff)</h2>\n<p>I read every diff. Every single one. If the copilot produced 200 lines and I needed 40, I kept the 40 and deleted the rest. If the copilot produced a pattern that was technically correct but did not match the rest of the codebase, I rewrote it.</p>\n<p>That is not a criticism of the copilot. It is the job. The copilot generates proposals; the engineer curates them. Anyone who skips the curation step is not engineering — they are editing.</p>\n<h2 id=\"what-i-never-delegated-the-line-in-the-sand\" tabindex=\"0\" data-toc-anchor=\"true\">What I never delegated (the line in the sand)</h2>\n<p>Architecture. Naming. Accessibility semantics. The CSP. URL design. Whether a given feature should exist at all.</p>\n<p>These stayed in my head. They are the difference between a codebase that is maintainable and a codebase that is \"working\" in the sense that a stopped clock is right twice a day.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Vibe coding is not a license to abandon standards. It is a license to move faster while applying the same standards you would apply to a human pair programmer. Scope every session. Write acceptance criteria. Read every diff. Keep the architecture decisions in your own head.</p>\n<p>Do that, and you can ship 19 milestones in weeks instead of months. Skip any of those, and you get a codebase that works today and is unfixable tomorrow.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-26-vibe-coding-with-rigor.png",
      "date_published": "2026-05-26T00:00:00.000Z",
      "tags": [
        "making-of",
        "ai",
        "workflow"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-23-ci-cd-around-github-pages.html",
      "url": "https://dout.dev/posts/2026-05-23-ci-cd-around-github-pages.html",
      "title": "CI/CD Around GitHub Pages (Or: How I Got Deploy Previews Without Paying Netlify)",
      "summary": "The gap GitHub Pages leaves open (it's not that bad)",
      "content_html": "<h2 id=\"the-gap-github-pages-leaves-open-it-s-not-that-bad\" tabindex=\"0\" data-toc-anchor=\"true\">The gap GitHub Pages leaves open (it's not that bad)</h2>\n<p>GitHub Pages is excellent for serving a static site. It is also, by default, missing two features that Netlify and Vercel have made everyone expect: <strong>deploy previews on pull requests</strong>, and <strong>one-click rollback</strong> to a previous deploy.</p>\n<p><em>Neither is actually missing.</em> They are just not turned on. The pieces exist in GitHub Actions; you have to wire them up. On dout.dev, that wiring is about 40 lines of YAML and one simple naming convention.</p>\n<h2 id=\"the-production-pipeline-boring-stable-working\" tabindex=\"0\" data-toc-anchor=\"true\">The production pipeline (boring, stable, working)</h2>\n<p>Production deploys run from <code>main</code> or from <code>workflow_dispatch</code>. The workflow has two jobs — build and deploy — and produces a site at <code>https://dout.dev</code>.</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>name: Deploy Pages\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n      - uses: actions/upload-pages-artifact@v3\n        with: { path: dist }\n\n  deploy:\n    needs: build\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n      url: https://dout.dev\n    steps:\n      - uses: actions/deploy-pages@v4</code></pre><p>That is a complete Pages workflow. The <code>concurrency</code> group prevents two overlapping deploys from fighting. The <code>environment: github-pages</code> line is required for the Pages deployment to succeed and also records deploy history.</p>\n<h2 id=\"pr-previews-without-a-third-party-service-the-clever-part\" tabindex=\"0\" data-toc-anchor=\"true\">PR previews, without a third-party service (the clever part)</h2>\n<p>GitHub Pages has one \"live\" site per repository. You cannot deploy a PR to a live preview URL the way Netlify does.</p>\n<p>What you CAN do: <strong>build the PR, upload the <code>dist/</code> as an artifact, and comment on the PR with a link to download it.</strong> A reviewer can extract the zip locally and open it. For a single-author blog, that is often enough.</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>name: PR Build\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n      - uses: actions/upload-artifact@v4\n        with:\n          name: dist-PR_NUMBER\n          path: dist\n          retention-days: 14</code></pre><p>The artifact is named with the PR number, so CI history stays navigable. Retention is two weeks — long enough to review, short enough to not accumulate forever.</p>\n<h2 id=\"rollback-in-one-minute-the-feature-you-hope-you-never-need\" tabindex=\"0\" data-toc-anchor=\"true\">Rollback in one minute (the feature you hope you never need)</h2>\n<p>This is the feature I use more often than I expected.</p>\n<p>When a bad deploy reaches production — wrong content, broken CSP, accidentally unpublished post — the recovery path is:</p>\n<ol>\n<li>Go to Actions → Deploy Pages in the repo.</li><li>Find the last known-good run.</li><li>Click \"Re-run all jobs.\"</li></ol>\n<p>That rebuilds the old commit and redeploys it. One minute from \"oh no\" to \"fixed.\"</p>\n<p>The prerequisites:</p>\n<ul>\n<li><strong>Every production deploy is triggered by a commit on <code>main</code>.</strong> No manual artifact uploads. Every deploy is reproducible from a specific SHA.</li><li><strong>Build-time inputs are either committed or in secrets.</strong> If a deploy depends on an environment variable that is not tracked, re-running the old commit with a new secret value produces a different artifact. That is the most common rollback failure mode.</li></ul>\n<p>The workflow at the top of this post satisfies both. That is why it rolls back cleanly.</p>\n<h2 id=\"the-commit-based-deploy-trail-audit-for-free\" tabindex=\"0\" data-toc-anchor=\"true\">The commit-based deploy trail (audit for free)</h2>\n<p>A useful side effect of \"every deploy is a commit\" is that the Actions history is the deploy history. You can see which commit is live right now, which commits have been deployed before, and how long each deploy took.</p>\n<p>The Pages environment in the repo settings also tracks \"Active deployment\" and shows the deployed commit SHA at the top. If you have to answer \"what is live right now?\" for a coworker, that screen is the authoritative source.</p>\n<h2 id=\"what-i-did-not-add-because-over-engineering-is-a-sin\" tabindex=\"0\" data-toc-anchor=\"true\">What I did NOT add (because over-engineering is a sin)</h2>\n<ul>\n<li><strong>Automatic atomic rollback on failure.</strong> If the build fails, no deploy happens. If the deploy itself fails, GitHub Pages does not update. That is the correct level of automation for a static site.</li><li><strong>Slack or email notifications on deploy.</strong> Overkill for a single-author blog. The Actions email notifications are enough.</li><li><strong>Canary deploys.</strong> A static blog does not need canaries. The unit of change is one post.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>GitHub Pages with GitHub Actions is a complete deploy pipeline, including previews and rollback, if you accept that previews come as artifacts rather than live URLs. For most personal projects that is the right trade-off — no third-party service, no extra auth, and the same UI you are already using for the repo.</p>\n<p>If your blog is not on GitHub Pages with a proper deploy pipeline in 2026, what are you even doing?</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-23-ci-cd-around-github-pages.png",
      "date_published": "2026-05-23T00:00:00.000Z",
      "tags": [
        "making-of",
        "ci",
        "deployment",
        "github-pages"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-19-quality-gate-lint-format-verify.html",
      "url": "https://dout.dev/posts/2026-05-19-quality-gate-lint-format-verify.html",
      "title": "Quality Gate: Linting, Formatting, and Verify (Or: How I Stopped Shipping Broken HTML)",
      "summary": "Why the gate exists (because you will ship shit if you don't)",
      "content_html": "<h2 id=\"why-the-gate-exists-because-you-will-ship-shit-if-you-don-t\" tabindex=\"0\" data-toc-anchor=\"true\">Why the gate exists (because you will ship shit if you don't)</h2>\n<p>A single-author blog does not need CI the way a team product does. But any site that accepts drive-by contributions, or any site that I want to be able to touch in six months without re-learning, benefits from a gate that answers one question at merge time:</p>\n<blockquote>\n<p>Does this change still produce a correct, accessible, valid site?</p>\n</blockquote>\n<p>The gate on dout.dev is a single command, <strong><code>pnpm quality:check</code></strong>, that runs the same checks locally and in GitHub Actions. If it is green, the site is publishable. If it is red, it tells me which layer broke. That's it. No excuses.</p>\n<h2 id=\"what-the-gate-actually-runs-five-commands-one-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">What the gate actually runs (five commands, one purpose)</h2>\n<pre is=\"pix-highlighter\" data-lang=\"bash\"><code>pnpm test\npnpm spellcheck\npnpm format:check\npnpm format:check:html\npnpm validate:all</code></pre><p>Five commands, each doing one thing. No overlap. No ambiguity.</p>\n<p><strong><code>pnpm test</code></strong> — Node's built-in test runner on the CMS and template-engine code. Fast. No Jest, no Vitest, no configuration surface. Just tests.</p>\n<p><strong><code>pnpm spellcheck</code></strong> — cspell against the markdown and HTML files. Catches typos in posts and navigation labels BEFORE they reach the reader.</p>\n<p><strong><code>pnpm format:check</code></strong> — Biome in check mode over JavaScript. Enforces the single formatter.</p>\n<p><strong><code>pnpm format:check:html</code></strong> — Prettier in check mode over HTML templates. HTML formatting is one of those things everyone forgets to verify until a diff becomes impossible to review.</p>\n<p><strong><code>pnpm validate:all</code></strong> — a composite of HTML validation, structural validation, link checking, and accessibility validation. The one that catches the most real bugs.</p>\n<h2 id=\"html-validation-the-one-nobody-does-but-everyone-should\" tabindex=\"0\" data-toc-anchor=\"true\">HTML validation (the one nobody does but everyone should)</h2>\n<p>HTML validation is underrated. Bad nesting, missing alt text, heading skips, unused IDs — all of these slip past manual review and all of them matter. The validator on dout.dev runs against every file under <code>src/</code> and <code>dist/</code> and reports problems with file and line.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { HTMLHint } from 'htmlhint';\n\nfunction validateHtml(file) {\n  const rules = {\n    'doctype-first': true,\n    'tag-pair': true,\n    'attr-lowercase': true,\n    'alt-require': true,\n    'id-unique': true,\n  };\n  const source = readFileSync(file, 'utf8');\n  return HTMLHint.verify(source, rules);\n}</code></pre><p>HTMLHint is not the only option. For deep validation the W3C Nu Validator is authoritative. For this project HTMLHint is enough. \"Enough\" is a feature.</p>\n<h2 id=\"link-validation-every-link-every-build\" tabindex=\"0\" data-toc-anchor=\"true\">Link validation (every link, every build)</h2>\n<p>Every internal link on the site gets resolved to an actual file at build time. A broken link in a post or in the navigation FAILS the build.</p>\n<p>External links are checked only occasionally, not on every build, because network flakiness should not fail a PR. A scheduled task runs external-link validation once a week.</p>\n<h2 id=\"accessibility-validation-axe-me-anything\" tabindex=\"0\" data-toc-anchor=\"true\">Accessibility validation (axe me anything)</h2>\n<p>The a11y check runs axe-core over every generated page, using Playwright as the headless browser. It catches a significant fraction of WCAG issues automatically — missing labels, insufficient contrast, heading order problems, keyboard trap candidates.</p>\n<p>Axe does not catch everything. Nothing does. What it catches is the base rate of failures that keep creeping in through drive-by edits. That's good enough.</p>\n<h2 id=\"the-one-i-added-after-getting-burned-we-all-have-scars\" tabindex=\"0\" data-toc-anchor=\"true\">The one I added after getting burned (we all have scars)</h2>\n<p>Early in the project, I shipped a build with a broken CSP meta tag. The page rendered; the browser silently refused to load the JavaScript. The fix is a build-time check that verifies the generated CSP is well-formed and covers the scripts referenced in the HTML.</p>\n<p>This is the kind of check you write exactly once, after exactly one production incident. Learn from my pain.</p>\n<h2 id=\"the-gate-lives-in-one-file-single-source-of-truth\" tabindex=\"0\" data-toc-anchor=\"true\">The gate lives in one file (single source of truth)</h2>\n<p>All of this is wired in <code>.github/workflows/deploy-pages.yml</code> as a single step:</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>- name: Build site\n  run: pnpm build\n\n- name: Quality gate\n  run: pnpm quality:check</code></pre><p>Locally I run <code>pnpm quality:check</code> before any push. If I am about to merge something significant, I also run <code>pnpm test:visual</code> — the Playwright visual regression suite — which is slower and therefore not on the default path.</p>\n<h2 id=\"what-i-did-not-add-because-scope-is-a-feature\" tabindex=\"0\" data-toc-anchor=\"true\">What I did NOT add (because scope is a feature)</h2>\n<ul>\n<li><strong>No commit message linting.</strong> I write commits in the shape I need; convention beats enforcement for a single-author repo.</li><li><strong>No bundle size budget as a hard gate.</strong> I check it occasionally with Lighthouse. It has never regressed enough to warrant a budget check.</li><li><strong>No dependency update bot.</strong> Dependabot PRs on a personal site are noise. I update manually when something actually needs updating.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A quality gate is a contract with your future self. Write it once, keep it small, run it everywhere. The specific checks that matter depend on the project. For a static editorial site, HTML validity, link integrity, and axe-core accessibility cover most of what goes wrong.</p>\n<p>Ship shit, get paged. Gate your shit, sleep well.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-19-quality-gate-lint-format-verify.png",
      "date_published": "2026-05-19T00:00:00.000Z",
      "tags": [
        "making-of",
        "tooling",
        "linting",
        "ci"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-16-seo-metadata-without-rituals.html",
      "url": "https://dout.dev/posts/2026-05-16-seo-metadata-without-rituals.html",
      "title": "SEO Metadata Without Rituals (Or: I Read 47 SEO Blog Posts So You Don't Have To)",
      "summary": "The short version (TL;DR for the impatient)",
      "content_html": "<h2 id=\"the-short-version-tl-dr-for-the-impatient\" tabindex=\"0\" data-toc-anchor=\"true\">The short version (TL;DR for the impatient)</h2>\n<p>\"SEO\" is a category name. Inside it are a dozen unrelated concerns, some of which matter for most sites and many of which do not. This post is the handful of tags I ship on dout.dev because they demonstrably help crawlers and discovery, and the handful I do NOT ship because they are noise.</p>\n<p><strong>The line I draw:</strong> a tag earns its place if a major crawler uses it or if it improves a demonstrable discovery behavior. If it is \"recommended\" but nobody can point to the behavior it improves, I leave it out.</p>\n<h2 id=\"canonical-urls-one-real-url-per-page-not-that-hard\" tabindex=\"0\" data-toc-anchor=\"true\">Canonical URLs: one real URL per page (not that hard)</h2>\n<p>Every page on dout.dev has a canonical URL in the head. Every single one. No exceptions.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"canonical\" href=\"https://dout.dev/posts/some-post.html\" /&gt;</code></pre><p>The value is the absolute URL of the page as it lives on the site. Three rules. Follow them.</p>\n<ol>\n<li><strong>Always absolute.</strong> Relative canonicals work but are ambiguous in practice. Don't be ambiguous.</li><li><strong>Always HTTPS.</strong> Never redirect through HTTP; the canonical declares HTTPS directly.</li><li><strong>Self-referential on normal pages.</strong> A post's canonical points at itself. An archive's canonical points at itself.</li></ol>\n<p>Where canonical matters most is when the same content is reachable via multiple URLs. Pagination is the classic case: <code>/tags/accessibility/2/</code> should still canonical to itself, not to <code>/tags/accessibility/</code>. On a clean static site with one URL per page, canonical is mostly boilerplate. It is still worth shipping, because the day a duplicate URL appears (through a short-link service, a Mastodon share, a paginated archive), the canonical is the difference between a clean index and content fragmentation.</p>\n<h2 id=\"structured-data-json-ld-for-articles-the-one-that-actually-matters\" tabindex=\"0\" data-toc-anchor=\"true\">Structured data: JSON-LD for articles (the one that actually matters)</h2>\n<p>JSON-LD is the format Google, Bing, and LinkedIn want for structured data. It is a <code>&lt;script type=\"application/ld+json\"&gt;</code> block in the head.</p>\n<pre is=\"pix-highlighter\" data-lang=\"json\"><code>{\n  \"@context\": \"https://schema.org\",\n  \"@type\": \"BlogPosting\",\n  \"headline\": \"...\",\n  \"datePublished\": \"...\",\n  \"author\": { \"@type\": \"Person\", \"name\": \"...\" },\n  \"image\": \"...\",\n  \"publisher\": { \"@type\": \"Organization\", \"name\": \"dout.dev\" }\n}</code></pre><p>That block earns its place because:</p>\n<ul>\n<li><strong>Google Discover</strong> uses it to decide whether a post is article content.</li><li><strong>Mastodon, Slack, and some RSS readers</strong> enrich link previews when structured data is present.</li><li><strong>Google Search Console</strong> reports structured data errors, which means the build can be verified.</li></ul>\n<p>The post generator emits this block from the normalized post record. One source, per-post substitution, no manual upkeep.</p>\n<h2 id=\"what-i-do-not-ship-as-structured-data-say-no-to-schema-spam\" tabindex=\"0\" data-toc-anchor=\"true\">What I do NOT ship as structured data (say no to schema spam)</h2>\n<p><code>WebSite</code> with <code>SiteNavigationElement</code>, <code>BreadcrumbList</code> on posts that have no breadcrumb UI, <code>Article</code> alongside <code>BlogPosting</code>, <code>@type</code> schemas for which no consumer has a documented behavior.</p>\n<p>The heuristic: if I cannot point at a concrete consumer behavior, I do NOT ship the schema. \"Might help ranking someday\" is not a reason. Schema spam has a negative cost with some crawlers, which explicitly penalize sites that dump irrelevant structured data.</p>\n<h2 id=\"hreflang-only-when-it-applies-it-doesn-t-for-me\" tabindex=\"0\" data-toc-anchor=\"true\">Hreflang: only when it applies (it doesn't, for me)</h2>\n<p><code>hreflang</code> tells crawlers which language a page is in and what the equivalents are in other languages. dout.dev is English only. The roadmap removed the i18n milestone. So hreflang is a no-op for my case and I do not ship it.</p>\n<p>Writing it out here for completeness: the moment a site has localized content, hreflang is non-optional, and omitting it is a real SEO hit.</p>\n<h2 id=\"open-graph-and-twitter-cards-the-preview-matters\" tabindex=\"0\" data-toc-anchor=\"true\">Open Graph and Twitter Cards (the preview matters)</h2>\n<p>These are not strictly SEO, but they are discovery. Every post has the full set: <code>og:site_name</code>, <code>og:type</code>, <code>og:title</code>, <code>og:description</code>, <code>og:url</code>, <code>og:image</code>, <code>og:locale</code>, <code>article:published_time</code>, <code>twitter:card</code>.</p>\n<p>The <code>og:image:width</code> and <code>og:image:height</code> are NOT optional in practice. Mastodon's preview system, in particular, falls back to a smaller thumbnail without them. Ask me how I know.</p>\n<h2 id=\"the-robots-and-meta-pragmas-that-matter-keep-it-tight\" tabindex=\"0\" data-toc-anchor=\"true\">The robots and meta pragmas that matter (keep it tight)</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;meta name=\"robots\" content=\"index,follow\" /&gt;\n&lt;meta name=\"referrer\" content=\"strict-origin-when-cross-origin\" /&gt;</code></pre><p>Drafts (<code>published: false</code>) never render, so there is no need for a <code>noindex</code> path for them. <code>referrer</code> is a privacy choice.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>SEO on a blog is not magic. Three tags — canonical, structured data, Open Graph — cover most of the actual behavior. Hreflang only when you have multiple languages. Skip the dozens of \"recommended\" tags unless you can point at the consumer behavior they enable.</p>\n<p>The goal is a clean, honest document, not a Christmas tree of meta tags.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-16-seo-metadata-without-rituals.png",
      "date_published": "2026-05-16T00:00:00.000Z",
      "tags": [
        "making-of",
        "seo",
        "opengraph",
        "html"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-12-og-images-at-build-time.html",
      "url": "https://dout.dev/posts/2026-05-12-og-images-at-build-time.html",
      "title": "OG Images at Build Time (Or: How I Stopped Worrying and Learned to Love Sharp)",
      "summary": "The problem and the usual solutions (none of which I wanted)",
      "content_html": "<h2 id=\"the-problem-and-the-usual-solutions-none-of-which-i-wanted\" tabindex=\"0\" data-toc-anchor=\"true\">The problem and the usual solutions (none of which I wanted)</h2>\n<p>Every post needs a social preview image — the card that appears when someone shares the URL on Slack, Twitter, or LinkedIn. The options usually break down like this:</p>\n<ul>\n<li><strong>Hand-design each one.</strong> A Figma file, a designer, exported PNGs. Works. Does not scale.</li><li><strong>Runtime service</strong> (Vercel OG, Cloudinary). A URL like <code>og.dev/render?title=...</code> produces an image on demand. Works. Adds a third-party dependency, latency, and a potential point of failure.</li><li><strong>Build-time renderer.</strong> Generate the PNG when the site builds, commit it to the static output. Works. Is under your control.</li></ul>\n<p>For dout.dev I went build-time, for one reason: <strong>I wanted the image to be a byproduct of the build, cacheable as a static file, with zero runtime dependency.</strong> The implementation is about 150 lines of Node. That's it.</p>\n<h2 id=\"the-svg-template-the-trick-that-makes-it-work\" tabindex=\"0\" data-toc-anchor=\"true\">The SVG template (the trick that makes it work)</h2>\n<p>The trick is that OG images are rasterized SVGs. SVG is a markup language I can generate with the same template engine as the rest of the site. Sharp then converts the SVG to PNG at 1200×630, which is the OG card spec.</p>\n<pre is=\"pix-highlighter\" data-lang=\"xml\"><code>&lt;svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\"&gt;\n  &lt;rect width=\"1200\" height=\"630\" fill=\"#0b0b0f\" /&gt;\n  &lt;text x=\"80\" y=\"180\" font-family=\"Inter, sans-serif\" font-size=\"24\" fill=\"#ff6b3d\"\n        font-weight=\"600\" letter-spacing=\"0.08em\"&gt;DOUT.DEV&lt;/text&gt;\n  &lt;text x=\"80\" y=\"320\" font-family=\"Inter, sans-serif\" font-size=\"64\" fill=\"#e7e7ef\"\n        font-weight=\"700\" style=\"line-height: 1.1\"&gt;\n    &lt;tspan x=\"80\" dy=\"0\"&gt;OG Images at Build Time&lt;/tspan&gt;\n    &lt;tspan x=\"80\" dy=\"80\"&gt;SVG + Sharp in 150 Lines&lt;/tspan&gt;\n  &lt;/text&gt;\n  &lt;text x=\"80\" y=\"540\" font-family=\"Inter, sans-serif\" font-size=\"24\" fill=\"#a0a0b4\"&gt;\n    by Some Nerd · Some Date\n  &lt;/text&gt;\n&lt;/svg&gt;</code></pre><p>That template has variables where the title, date, and author go. The generator produces one per post. Simple. Elegant. No bullshit.</p>\n<h2 id=\"the-line-break-problem-harder-than-it-looks\" tabindex=\"0\" data-toc-anchor=\"true\">The line-break problem (harder than it looks)</h2>\n<p>The hardest single problem in OG image generation is fitting a variable-length title inside a fixed-width box without it running off the edge. You'd think this would be solved. You'd be wrong.</p>\n<p>The brute-force solution — \"measure the text, wrap manually\" — requires a font metrics library. The simpler solution — greedy wrapping with a conservative line-length budget — is what I use.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function wrapTitle(title, maxChars = 26) {\n  const words = title.split(/\\s+/);\n  const lines = [];\n  let current = '';\n  for (const word of words) {\n    if ((current + ' ' + word).trim().length &gt; maxChars) {\n      lines.push(current);\n      current = word;\n    } else {\n      current = (current + ' ' + word).trim();\n    }\n  }\n  if (current) lines.push(current);\n  return lines.slice(0, 3);\n}</code></pre><p>26 characters is an empirical value for Inter at 64px in a 1040px box with 80px margins. I tuned it once, compared against the longest real titles, and moved on. The generator truncates to three lines because four looks cramped.</p>\n<h2 id=\"the-rasterization-sharp-go-brrr\" tabindex=\"0\" data-toc-anchor=\"true\">The rasterization (Sharp, go brrr)</h2>\n<p>Sharp handles the SVG-to-PNG conversion. It is a one-liner with reasonable defaults.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import sharp from 'sharp';\n\nasync function renderOg(svg, outputPath) {\n  await sharp(Buffer.from(svg)).resize(1200, 630).png({ compressionLevel: 9 }).toFile(outputPath);\n}</code></pre><p><code>compressionLevel: 9</code> squeezes the PNG a bit at the cost of build time. On a small blog the total is under a second per image, so it is worth it.</p>\n<h2 id=\"the-full-generator-in-shape-150-lines-with-helpers\" tabindex=\"0\" data-toc-anchor=\"true\">The full generator, in shape (~150 lines with helpers)</h2>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { readFileSync } from 'node:fs';\nimport sharp from 'sharp';\n\nconst template = readFileSync('src/og-template.svg', 'utf8');\n\nexport async function generateOgImage(post, outputPath) {\n  const lines = wrapTitle(post.title);\n  const titleSvg = lines.map((line, i) =&gt;\n    `&lt;tspan x=\"80\" dy=\"${i === 0 ? 0 : 80}\"&gt;${escapeXml(line)}&lt;/tspan&gt;`\n  ).join('');\n\n  const svg = template\n    .replace('&lt;title-placeholder&gt;', titleSvg)\n    .replace('&lt;date-placeholder&gt;', formatDate(post.date))\n    .replace('&lt;author-placeholder&gt;', 'Some Nerd');\n\n  await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toFile(outputPath);\n}</code></pre><p>150 lines with the helpers included. No fonts-as-files wizardry. No headless browser. No Puppeteer. No \"let's spin up Chrome just to render an image.\" That would be insane.</p>\n<h2 id=\"fonts-the-caveat-you-have-to-handle-because-nothing-is-free\" tabindex=\"0\" data-toc-anchor=\"true\">Fonts: the caveat you have to handle (because nothing is free)</h2>\n<p>SVG <code>font-family=\"Inter\"</code> only works if the system rendering the SVG has Inter installed, which is not the case on GitHub Actions runners. Because why would it be?</p>\n<p>Two paths:</p>\n<ol>\n<li><strong>Embed the font as a base64 data URL inside the SVG.</strong> Works. Balloons the file size.</li><li><strong>Preload the font at the Sharp layer.</strong> Sharp uses the system fontconfig; you install the font on the runner and reference it by name.</li></ol>\n<p>On dout.dev I went with option 2: a GitHub Actions step installs Inter before the build runs. That keeps the SVG clean and the bundles lean.</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>- name: Install Inter font\n  run: |\n    wget -q https://fonts.google.com/download?family=Inter -O inter.zip\n    unzip -q inter.zip -d /usr/share/fonts/inter\n    fc-cache -f</code></pre><p>The URL for Google Fonts bulk download is stable enough; if it breaks, I self-host the <code>.ttf</code> in the repo.</p>\n<h2 id=\"what-the-generator-outputs-the-artifacts\" tabindex=\"0\" data-toc-anchor=\"true\">What the generator outputs (the artifacts)</h2>\n<p>One PNG per post, under <code>src/assets/og/posts/&lt;slug&gt;.png</code>. The CMS also generates month OG images under <code>src/assets/og/months/</code> and writes a manifest at <code>src/assets/og/manifest.json</code> so other build steps can reference them.</p>\n<p>The post template references the image in the <code>&lt;head&gt;</code>:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;meta property=\"og:image\" content=\"https://dout.dev/assets/og/posts/some-post.png\" /&gt;\n&lt;meta property=\"og:image:width\" content=\"1200\" /&gt;\n&lt;meta property=\"og:image:height\" content=\"630\" /&gt;\n&lt;meta name=\"twitter:card\" content=\"summary_large_image\" /&gt;</code></pre><p><code>og:image:width</code> and <code>og:image:height</code> are not strictly required, but many crawlers check them and Mastodon's preview service will happily show a tiny image if dimensions are missing. Like a thumbnail from 2005.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>You do NOT need a runtime OG service, a headless browser, or a third-party API. A templated SVG, a wrapping function, and Sharp cover the whole problem in 150 lines. It runs at build time, commits to <code>dist/</code>, and costs nothing after the first build.</p>\n<p>No server. No API key. No \"we're sorry, the OG image service is down.\" Just files.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-12-og-images-at-build-time.png",
      "date_published": "2026-05-12T00:00:00.000Z",
      "tags": [
        "making-of",
        "opengraph",
        "seo",
        "static-site"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-09-feeds-and-sitemaps-at-build-time.html",
      "url": "https://dout.dev/posts/2026-05-09-feeds-and-sitemaps-at-build-time.html",
      "title": "Feeds and Sitemaps at Build Time (RSS Is Dead, Long Live RSS. Also JSON Feed.)",
      "summary": "The distribution minimum (three files, that's it)",
      "content_html": "<h2 id=\"the-distribution-minimum-three-files-that-s-it\" tabindex=\"0\" data-toc-anchor=\"true\">The distribution minimum (three files, that's it)</h2>\n<p>A blog that does not ship feeds and a sitemap is a blog that nobody can reliably subscribe to, and a blog that crawlers have to guess at. Both problems are fixed with three files:</p>\n<ul>\n<li><code>feed.rss</code> for the greybeards who still use actual RSS readers (I love you people);</li><li><code>feed.json</code> for the modern readers who prefer JSON Feed (I also love you);</li><li><code>sitemap.xml</code> for crawlers (I tolerate you).</li></ul>\n<p>On dout.dev <strong>all three are generated at build time</strong> from the same normalized dataset the page generator uses. No external tooling. No runtime hit. No excuses.</p>\n<h2 id=\"rss-2-0-is-still-the-lingua-franca-yes-in-2026\" tabindex=\"0\" data-toc-anchor=\"true\">RSS 2.0 is still the lingua franca (yes, in 2026)</h2>\n<p>Despite being old enough to drink in most countries, RSS remains the format that every reader supports. If you ship exactly one feed, ship RSS. The schema is tiny and the templating is a for-loop.</p>\n<pre is=\"pix-highlighter\" data-lang=\"xml\"><code>&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\"&gt;\n  &lt;channel&gt;\n    &lt;title&gt;dout.dev&lt;/title&gt;\n    &lt;link&gt;https://dout.dev&lt;/link&gt;\n    &lt;description&gt;Vanilla-first static blog on web standards and AI-assisted engineering.&lt;/description&gt;\n    &lt;language&gt;en-us&lt;/language&gt;\n    &lt;lastBuildDate&gt;Mon, 30 Jun 2026 08:00:00 GMT&lt;/lastBuildDate&gt;\n    &lt;atom:link href=\"https://dout.dev/feed.rss\" rel=\"self\" type=\"application/rss+xml\" /&gt;\n    &lt;item&gt;\n      &lt;title&gt;How Feeds Work at Build Time&lt;/title&gt;\n      &lt;link&gt;https://dout.dev/posts/feeds.html&lt;/link&gt;\n      &lt;guid isPermaLink=\"true\"&gt;https://dout.dev/posts/feeds.html&lt;/guid&gt;\n      &lt;pubDate&gt;Tue, 30 Jun 2026 08:00:00 GMT&lt;/pubDate&gt;\n      &lt;description&gt;&lt;![CDATA[The three distribution files every blog should ship...]]&gt;&lt;/description&gt;\n    &lt;/item&gt;\n  &lt;/channel&gt;\n&lt;/rss&gt;</code></pre><p>Two things are easy to get wrong here. Don't get them wrong.</p>\n<p><strong>The <code>lastBuildDate</code> and <code>pubDate</code> must be RFC 822.</strong> Not ISO. Not \"a date.\" RFC 822 with a four-digit year is the correct shape. Readers will silently drop items that do not parse. I learned this the hard way so you don't have to.</p>\n<p><strong>The <code>&lt;atom:link rel=\"self\"&gt;</code> is not optional.</strong> It is the self-reference that tells aggregators where the feed lives. Some validators treat it as a warning; some treat it as a bug; some readers ignore it. Ship it anyway.</p>\n<h2 id=\"json-feed-is-not-a-curiosity-it-actually-rocks\" tabindex=\"0\" data-toc-anchor=\"true\">JSON Feed is not a curiosity (it actually rocks)</h2>\n<p>JSON Feed is worth shipping alongside RSS. The format is human-readable, trivial to parse, and the spec is under one screen. Readers like NetNewsWire and Readwise speak it natively.</p>\n<pre is=\"pix-highlighter\" data-lang=\"json\"><code>{\n  \"version\": \"https://jsonfeed.org/version/1.1\",\n  \"title\": \"dout.dev\",\n  \"home_page_url\": \"https://dout.dev\",\n  \"feed_url\": \"https://dout.dev/feed.json\",\n  \"items\": [\n    {\n      \"id\": \"https://dout.dev/posts/some-post.html\",\n      \"url\": \"https://dout.dev/posts/some-post.html\",\n      \"title\": \"Feeds at Build Time\",\n      \"date_published\": \"2026-06-30T08:00:00Z\",\n      \"tags\": [\"seo\", \"static-site\"]\n    }\n  ]\n}</code></pre><p>The cost of adding it is an extra file and twenty lines of generator code. The benefit is any reader that speaks JSON Feed gets richer metadata — tags, summaries, author — without the ceremony of extension namespaces in XML. Twenty lines. Do it.</p>\n<h2 id=\"sitemap-every-url-or-no-url-pick-one\" tabindex=\"0\" data-toc-anchor=\"true\">Sitemap: every URL or no URL (pick one)</h2>\n<p>The sitemap is for crawlers. If you ship it, ship EVERYTHING. Partial sitemaps are worse than no sitemap because they implicitly tell the crawler \"these are the interesting pages,\" which de-weights everything not listed.</p>\n<p>The dout.dev sitemap includes posts, tag pages, month archives, series archives, the home page, the about page, and the search page. It does NOT include the 404, the offline page, or the playground.</p>\n<p>The <code>changefreq</code> and <code>priority</code> fields are advisory. Google has said publicly that it mostly ignores them. I still ship them because other crawlers read them and the cost is nothing. Zero-cost optional metadata is always a yes.</p>\n<h2 id=\"per-tag-and-per-month-rss-for-people-who-give-a-shit-about-one-topic\" tabindex=\"0\" data-toc-anchor=\"true\">Per-tag and per-month RSS (for people who give a shit about one topic)</h2>\n<p>An underrated feature: each tag page and each month archive on dout.dev has its own RSS feed. A reader who cares about <code>accessibility</code> but not about <code>css</code> can subscribe to <code>/tags/accessibility.xml</code> and never see a post outside that tag.</p>\n<p>The feeds are generated from the same dataset as the main feed, filtered to the tag or month. It is free to produce, because the normalized data already has the tag and month indexes. Free as in beer and free as in effort.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"alternate\" type=\"application/rss+xml\" title=\"dout.dev - accessibility\" href=\"/tags/accessibility.xml\" /&gt;</code></pre><p>That <code>&lt;link rel=\"alternate\"&gt;</code> is how readers auto-discover the feed. Every tag page ships it. Every month page ships it. No exceptions.</p>\n<h2 id=\"the-build-time-angle-the-part-that-actually-matters\" tabindex=\"0\" data-toc-anchor=\"true\">The build-time angle (the part that actually matters)</h2>\n<p>None of this happens at runtime. The feeds and sitemap are generated when the CMS builds, committed as artifacts in <code>dist/</code>, and served as plain static files by GitHub Pages. That means:</p>\n<ul>\n<li>No feed service to maintain.</li><li>No cache invalidation problem.</li><li>No rate limiting, no throttling, no 500 errors on publish spikes.</li><li>The feed is always consistent with the site it describes, because they come from the same build.</li></ul>\n<p>This is the elegance of static sites: everything is a file. Your feed is a file. Your sitemap is a file. Your images are files. Your pages are files. Files don't crash.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Shipping RSS, JSON Feed, and a sitemap is an afternoon of generator code. The payoff is measurable: crawlers find your content, readers subscribe, and the blog stops being a black box to the rest of the web. If your blog doesn't have feeds in 2026, fix that before you write another post.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-09-feeds-and-sitemaps-at-build-time.png",
      "date_published": "2026-05-09T00:00:00.000Z",
      "tags": [
        "making-of",
        "feeds",
        "seo",
        "static-site"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-05-build-assets-pipeline.html",
      "url": "https://dout.dev/posts/2026-05-05-build-assets-pipeline.html",
      "title": "Build Assets Pipeline (Or: How I Turned a Simple Blog Into an Image Processing Factory)",
      "summary": "The goal (four non-negotiable constraints)",
      "content_html": "<h2 id=\"the-goal-four-non-negotiable-constraints\" tabindex=\"0\" data-toc-anchor=\"true\">The goal (four non-negotiable constraints)</h2>\n<p><strong>Every image on this blog has to satisfy four constraints.</strong> Not \"nice to have.\" Non-negotiable. Like my morning coffee.</p>\n<ol>\n<li><strong>Correct size for the viewport.</strong> No 2000px hero shipped to a phone.</li><li><strong>Modern format where supported.</strong> WebP for browsers that accept it, raster fallback for the rest.</li><li><strong>Zero cumulative layout shift.</strong> Width and height reserved BEFORE the image downloads.</li><li><strong>Lazy on-reveal.</strong> Off-screen images do not block the main thread.</li></ol>\n<p>The pipeline that delivers that is entirely build-time. There is NO image CDN, no runtime resize, no magic. This post walks through each layer.</p>\n<h2 id=\"the-input-a-plain-markdown-image-looks-boring-does-magic\" tabindex=\"0\" data-toc-anchor=\"true\">The input: a plain markdown image (looks boring, does magic)</h2>\n<p>The markdown source looks like a normal image:</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code>![A keyboard on a wooden desk](../assets/images/keyboard.jpg)</code></pre><p>What makes it responsive is NOT a special syntax. It's the build pipeline that sees the local path and does work behind the scenes. Magic? No. Engineering.</p>\n<p>Optionally the author can provide a title meta string to override the default behavior:</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code>![Hero](../assets/images/hero.jpg 'Hero | srcset=../img/320.jpg 320w, ../img/640.jpg 640w | priority=high')</code></pre><p>Segments separated by <code>|</code> let you specify custom <code>srcset</code>, <code>sizes</code>, <code>loading</code>, and <code>priority</code>. For most images I omit all of that and let the pipeline fill in the defaults. Because defaults should be smart.</p>\n<h2 id=\"stage-1-variants-and-the-image-manifest-sharp-knives-hot-takes\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 1: variants and the image manifest (sharp knives, hot takes)</h2>\n<p>Before the CMS runs, an image pipeline step generates responsive variants of every image under <code>src/assets/images/</code>:</p>\n<ul>\n<li><code>image.jpg</code> → <code>image-320.jpg</code>, <code>image-640.jpg</code>, <code>image-960.jpg</code>, <code>image-1280.jpg</code>, NEVER upscaled;</li><li>a WebP base (<code>image.webp</code>) and matching WebP variants at each size.</li></ul>\n<p>The output of that step is a manifest file (<code>src/assets/images-manifest.json</code>) with entries like:</p>\n<pre is=\"pix-highlighter\" data-lang=\"json\"><code>{\n  \"/assets/images/keyboard.jpg\": {\n    \"width\": 1920,\n    \"height\": 1280,\n    \"variants\": [\n      { \"src\": \"/assets/images/keyboard-320.jpg\", \"width\": 320 },\n      { \"src\": \"/assets/images/keyboard-640.jpg\", \"width\": 640 }\n    ]\n  }\n}</code></pre><p>Sharp does the resizing. The manifest is the contract between the image step and the markdown renderer. Contracts are good. Trust me, I've been divorced.</p>\n<h2 id=\"stage-2-the-markdown-renderer-rewrites-img-into-picture-the-fun-part\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 2: the markdown renderer rewrites <code>&lt;img&gt;</code> into <code>&lt;picture&gt;</code> (the fun part)</h2>\n<p>When the renderer encounters an image with a local path, it looks up the manifest, computes <code>srcset</code> for WebP and raster sources, and emits a <code>&lt;picture&gt;</code> element:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;picture&gt;\n  &lt;source type=\"image/webp\" data-srcset=\"/assets/images/keyboard-320.webp 320w, ...\" /&gt;\n  &lt;source type=\"image/jpeg\" data-srcset=\"/assets/images/keyboard-320.jpg 320w, ...\" /&gt;\n  &lt;img src=\"/assets/images/keyboard.jpg\" alt=\"A keyboard on a wooden desk\"\n       width=\"1920\" height=\"1280\" loading=\"lazy\" decoding=\"async\" /&gt;\n  &lt;noscript&gt;\n    &lt;img src=\"/assets/images/keyboard-640.jpg\" alt=\"A keyboard on a wooden desk\" /&gt;\n  &lt;/noscript&gt;\n&lt;/picture&gt;</code></pre><p>Two details worth calling out because they matter:</p>\n<p><strong>Width and height come from the manifest.</strong> The browser reserves the correct aspect-ratio box BEFORE the pixels arrive, so there is ZERO layout shift when the image loads. This is the single most impactful CLS fix on any image-heavy page. If you're not doing this, you're doing it wrong.</p>\n<p><strong><code>data-srcset</code> instead of <code>srcset</code> by default.</strong> The real <code>srcset</code> is swapped in by a tiny script when the image enters the viewport, which is how the \"lazy on-reveal\" policy is enforced. The <code>&lt;noscript&gt;</code> fallback covers users who disable JavaScript. Because not everyone lives in a perfect world.</p>\n<h2 id=\"stage-3-the-tiny-observer-that-swaps-data-srcset-to-srcset-12-lines-no-framework\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 3: the tiny observer that swaps <code>data-srcset</code> to <code>srcset</code> (12 lines, no framework)</h2>\n<p>The runtime piece is small. Embarrassingly small:</p>\n<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      if (source.dataset.srcset) {\n        source.srcset = source.dataset.srcset;\n        source.removeAttribute('data-srcset');\n      }\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>That's the ENTIRE lazy-load layer. Everything else is declarative markup the browser understands natively. No library. No framework. No bullshit.</p>\n<h2 id=\"why-not-loading-lazy-alone-the-nuance-part\" tabindex=\"0\" data-toc-anchor=\"true\">Why not <code>loading=\"lazy\"</code> alone? (the nuance part)</h2>\n<p>Native <code>loading=\"lazy\"</code> is a good default and I use it on <code>&lt;img&gt;</code> elements. But it does NOT cover <code>&lt;source&gt;</code> elements inside <code>&lt;picture&gt;</code>. The IntersectionObserver swap handles the source selection path, which is where the large WebP variants are chosen.</p>\n<p>On a page with a dozen images below the fold, the difference is measurable in bytes saved on initial load. Measurable matters.</p>\n<h2 id=\"what-i-did-not-do-because-engineering-is-about-saying-no\" tabindex=\"0\" data-toc-anchor=\"true\">What I did NOT do (because engineering is about saying no)</h2>\n<ul>\n<li><strong>No runtime image CDN.</strong> Every variant is on disk, served by GitHub Pages, cached at the edge.</li><li><strong>No AVIF yet.</strong> AVIF support is excellent, but WebP still beats it on encode time for the gains at the sizes this blog uses. I will reconsider when AVIF encoders catch up. Not today.</li><li><strong>No third-party \"lazyload.js\".</strong> Twelve lines of IntersectionObserver replaces it. Twelve. Lines.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Responsive images do NOT need a service. A build step that generates variants, a manifest the renderer reads, and a markdown pass that emits <code>&lt;picture&gt;</code> are enough. The result is small pages, correct sizes, known dimensions, and lazy behavior that respects the user.</p>\n<p>No CDN. No runtime. No excuses.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-05-build-assets-pipeline.png",
      "date_published": "2026-05-05T00:00:00.000Z",
      "tags": [
        "making-of",
        "build-assets",
        "performance",
        "images"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-02-design-tokens-before-pages.html",
      "url": "https://dout.dev/posts/2026-05-02-design-tokens-before-pages.html",
      "title": "Design Tokens Before Pages (Yes, I Wrote the CSS Before the First Line of HTML)",
      "summary": "The mistake I kept making (repeat after me: tokens first)",
      "content_html": "<h2 id=\"the-mistake-i-kept-making-repeat-after-me-tokens-first\" tabindex=\"0\" data-toc-anchor=\"true\">The mistake I kept making (repeat after me: tokens first)</h2>\n<p>Every time I started a personal site and went \"page first, design system later,\" I ended up with a pile of local exceptions. A <code>margin-top: 24px</code> here, a <code>color: #f5a623</code> there, a breakpoint at <code>768px</code> on one component and <code>720px</code> on another. By the third page the design was already incoherent, and the only way to fix it was to go back and retrofit tokens onto a living codebase. That is the WORST moment to do it. Like painting the inside of your house after you've already moved in.</p>\n<p>For dout.dev I flipped the order. <strong>Tokens first, components second, pages third.</strong> This post is what that looks like in practice. No Figma. No design system conference ticket. Just CSS and discipline.</p>\n<h2 id=\"what-i-mean-by-tokens-not-a-design-system\" tabindex=\"0\" data-toc-anchor=\"true\">What I mean by \"tokens\" (not a design system)</h2>\n<p>Design tokens are NOT a library. They are a flat set of named decisions, expressed as CSS custom properties, about spacing, type, color, elevation, motion, and layout. The rest of the CSS reads those decisions and NEVER hardcodes.</p>\n<p>The rule I hold to is simple: <strong>if a value appears in two components, it becomes a token</strong>. If it only appears once, I wait. YAGNI applies to design systems too.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --space-1: 0.25rem;\n  --space-2: 0.5rem;\n  --space-3: 0.75rem;\n  --space-4: 1rem;\n  --space-5: 1.5rem;\n  --space-6: 2rem;\n  --space-8: 3rem;\n\n  --text-sm: 0.875rem;\n  --text-base: 1rem;\n  --text-lg: 1.125rem;\n  --text-xl: 1.25rem;\n\n  --surface-1: #0b0b0f;\n  --surface-2: #161621;\n  --text-primary: #e7e7ef;\n  --text-muted: #a0a0b4;\n\n  --focus-ring: 2px solid #ff6b3d;\n}</code></pre><p>That is a single source of truth. It does not know about pages. It does not know about components. It knows about DECISIONS.</p>\n<h2 id=\"two-layers-not-one-because-theming-is-not-optional\" tabindex=\"0\" data-toc-anchor=\"true\">Two layers, not one (because theming is not optional)</h2>\n<p>Raw tokens alone are not enough for a theme-able site. You need two layers. I use two layers. You should too.</p>\n<p><strong>Primitive tokens</strong> — the raw decisions above. They never change at runtime. They're the concrete values.</p>\n<p><strong>Semantic tokens</strong> — aliases that map intent to primitives. These are the ones components actually read, and these are the ones that flip between themes.</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}</code></pre><p>Components then write <code>color: var(--color-fg)</code>, never <code>color: #e7e7ef</code>. Theme switching becomes a matter of changing a handful of semantic tokens at the root, not rewriting every goddamn rule. This is how adults do CSS.</p>\n<h2 id=\"component-rules-i-enforce-no-seriously-i-enforce-them\" tabindex=\"0\" data-toc-anchor=\"true\">Component rules I enforce (no, seriously, I enforce them)</h2>\n<p>Three rules keep the CSS from rotting. Written down. Checked in code review.</p>\n<p><strong>No magic numbers in components.</strong> If a value is not a token, it is almost always a mistake. The exceptions are genuinely one-off values tied to a specific piece of geometry, and those get a comment explaining why.</p>\n<p><strong>No color literals outside the token file.</strong> Not even in media queries, not even in <code>box-shadow</code>. If a color is referenced, it is via a variable. Period.</p>\n<p><strong>No breakpoint literals outside a shared map.</strong> Breakpoints are tokens too, expressed as container query breakpoints or named media queries, never inline numbers.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Good */\n.post-card {\n  padding: var(--space-5);\n  background: var(--color-bg-raised);\n  color: var(--color-fg);\n}\n\n/* Bad */\n.post-card {\n  padding: 24px;\n  background: #161621;\n  color: #e7e7ef;\n  border-radius: 8px;\n}</code></pre><p>The \"bad\" version works. It will also be unfixable in a year. Trust me. I've been that guy.</p>\n<h2 id=\"the-payoff-during-theming-this-is-where-it-gets-good\" tabindex=\"0\" data-toc-anchor=\"true\">The payoff during theming (this is where it gets good)</h2>\n<p>When I added the light theme and the accent selector, the change was a few dozen lines. Every component picked it up for free, because no component hardcoded a single goddamn value. The dark mode toggle, the <code>prefers-color-scheme</code> hookup, and the accent switcher are all variations on the same idea: swap the semantic tokens at the root, let the cascade do the rest.</p>\n<p>Without the two-layer setup, theming a site usually means touching every component. WITH it, theming is one file. One. That's the power of not being an idiot.</p>\n<h2 id=\"where-i-drew-the-line-because-tools-are-tools-not-religions\" tabindex=\"0\" data-toc-anchor=\"true\">Where I drew the line (because tools are tools, not religions)</h2>\n<p>I did not build an elaborate token pipeline. No Style Dictionary, no JSON sources compiled into multiple formats, no cross-platform tokens. This is a single-target CSS project. A flat <code>.css</code> file that declares custom properties is enough, and any tooling beyond that would be overhead without a matching benefit.</p>\n<p>If the project grew multiplatform — iOS, Android, email, a design tool plugin — I would pull in Style Dictionary. Until then, one file and discipline. Discipline over tools. Every time.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>If you are starting a personal site or a small design system, WRITE THE TOKEN LAYER BEFORE THE FIRST PAGE. It costs an afternoon. It buys you theming, consistency, and the ability to make design changes in ONE PLACE instead of twenty. Your future self will thank you. Your present self will be confused about why you didn't do this years ago.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-02-design-tokens-before-pages.png",
      "date_published": "2026-05-02T00:00:00.000Z",
      "tags": [
        "making-of",
        "design-systems",
        "css"
      ]
    }
  ]
}
