{
  "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-25-accessibility-by-design.html",
      "url": "https://dout.dev/posts/2026-07-25-accessibility-by-design.html",
      "title": "Accessibility by Design (Not by Audit, Not by Checklist, by Design)",
      "summary": "We live in an era where technology permeates every aspect of our daily lives. However, not everyone can benefit equally from these advancements. This is where Accessibility by Desi",
      "content_html": "<p>We live in an era where technology permeates every aspect of our daily lives. However, not everyone can benefit equally from these advancements. This is where Accessibility by Design comes into play, a fundamental approach that must be incorporated from the early stages of digital product development. But why is it so important? And what are the advantages it offers in terms of inclusivity, responsiveness, usability, and marketing?</p>\n<h2 id=\"inclusivity-a-social-and-moral-goal\" tabindex=\"0\" data-toc-anchor=\"true\">Inclusivity: A Social and Moral Goal</h2>\n<p>Inclusivity is not just a buzzword; it is a fundamental principle of a fair and just society. About 20% of the world's population lives with some form of disability. Ignoring this significant portion of the population means excluding millions of people from accessing digital resources. Accessibility by Design ensures that our products are usable by everyone, regardless of their physical or cognitive abilities. This not only improves the quality of life for many people but also strengthens our social responsibility as a company.</p>\n<h2 id=\"responsiveness-adapting-to-everyone-s-needs\" tabindex=\"0\" data-toc-anchor=\"true\">Responsiveness: Adapting to Everyone's Needs</h2>\n<p>An accessible design makes digital products more responsive. This means that our software, website, or application can easily adapt to the diverse needs of users. Whether it's people with visual, auditory, motor, or cognitive disabilities, an accessibility-focused approach ensures that everyone can effectively interact with our product. This not only enhances the user experience but also ensures that there are no technological barriers to access.</p>\n<h2 id=\"usability-improving-the-experience-for-everyone\" tabindex=\"0\" data-toc-anchor=\"true\">Usability: Improving the Experience for Everyone</h2>\n<p>Often, accessible design brings significant improvements in overall usability. Features such as clear and readable text, adequate color contrasts, intuitive navigation, and audio/video descriptions not only help users with disabilities but also enhance the experience for everyone. A product that is easy to use is a successful product, capable of meeting the needs of a wide range of users.</p>\n<h2 id=\"marketing-a-competitive-advantage\" tabindex=\"0\" data-toc-anchor=\"true\">Marketing: A Competitive Advantage</h2>\n<p>From a marketing perspective, Accessibility by Design offers extraordinary benefits. With 20% of the world's population consisting of people with disabilities, we are talking about a vast segment of potential users, stakeholders, operators, and customers. Ignoring this segment means missing out on a significant market opportunity. On the other hand, creating accessible products can set our company apart from the competition, positioning us as leaders in the industry and enhancing our reputation.</p>\n<h2 id=\"conclusion\" tabindex=\"0\" data-toc-anchor=\"true\">Conclusion</h2>\n<p>Adopting Accessibility by Design is not just good practice but a winning strategy that encompasses inclusivity, responsiveness, usability, and marketing. It is an investment that pays off handsomely, improving our corporate image, expanding our user base, and demonstrating our commitment to a more equitable and inclusive society.</p>\n<p>Let's make accessibility a priority and work together to create a digital future that is truly for everyone.</p>\n<hr>\n<p>Accessibility by Design is not just a trend but a necessity. We are ready to make a difference. Join us on this journey toward a more inclusive digital world.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-25-accessibility-by-design.png",
      "date_published": "2026-07-25T00:00:00.000Z",
      "tags": [
        "accessibility",
        "design",
        "responsive-design"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-23-manual-a11y-audit.html",
      "url": "https://dout.dev/posts/2026-07-23-manual-a11y-audit.html",
      "title": "Manual A11y Audit: axe, Keyboard, Screen Reader in an Afternoon (You Have Time for This)",
      "summary": "The claim",
      "content_html": "<h2 id=\"the-claim\" tabindex=\"0\" data-toc-anchor=\"true\">The claim</h2>\n<p>An accessibility audit is not a three-week project. For a static blog of reasonable size, a complete manual audit - automated scan plus keyboard-only pass plus screen reader pass - fits in one afternoon. This post is the protocol I use.</p>\n<p>Three passes. Each catches a different class of problem. None replaces the others.</p>\n<h2 id=\"pass-1-axe-in-the-browser\" tabindex=\"0\" data-toc-anchor=\"true\">Pass 1: axe, in the browser</h2>\n<p>Axe-core is the de facto standard for automated a11y checks. It catches mechanical violations - missing labels, contrast failures, heading order skips, ARIA misuse, missing alt text, keyboard traps in known patterns. What it does not catch is semantic correctness and interaction-level issues.</p>\n<p>Time budget: 30 minutes for a small site.</p>\n<p>The easiest way to run axe is the browser extension. Install it, open DevTools, Scan. For deterministic reruns, the axe Chrome extension exports a JSON report; if you want CI integration, use the Playwright or Puppeteer bindings.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { test, expect } from '@playwright/test';\nimport { injectAxe, checkA11y } from '@axe-core/playwright';\n\ntest('home passes axe', async ({ page }) =&gt; {\n  await page.goto('/');\n  await injectAxe(page);\n  const violations = await checkA11y(page, null, {\n    detailedReport: true,\n    axeOptions: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag22aa'] } },\n  });\n  expect(violations).toBeFalsy();\n});</code></pre><p>Common findings on a first-time audit:</p>\n<ul>\n<li data-reveal=\"\">Color contrast failures on secondary text.</li><li data-reveal=\"\">Missing alt text on images that are actually decorative (use <code>alt=\"\"</code>, not no alt).</li><li data-reveal=\"\"><code>aria-label</code> on elements that already have visible accessible text.</li><li data-reveal=\"\">Nested interactive elements (a <code>&lt;button&gt;</code> inside an <code>&lt;a&gt;</code>).</li><li data-reveal=\"\">Empty headings.</li></ul>\n<p>Fix these first. They are mechanical; they do not need judgment. Axe will flag them again if they come back.</p>\n<h2 id=\"pass-2-keyboard-only\" tabindex=\"0\" data-toc-anchor=\"true\">Pass 2: keyboard only</h2>\n<p>Unplug your mouse. Literally, or at least commit to not using it.</p>\n<p>Time budget: 45 minutes.</p>\n<p>The protocol:</p>\n<ol>\n<li data-reveal=\"\"><strong>Tab through the home page.</strong> Every interactive element should receive a visible focus ring. The ring should never be invisible or cut off.</li><li data-reveal=\"\"><strong>Activate each interactive element with Enter or Space.</strong> Buttons work with Space; links with Enter; both should do the right thing.</li><li data-reveal=\"\"><strong>Navigate to a post.</strong> Scroll with arrow keys or Page Down. Tab through the outline. Click an outline link - focus should land inside the section.</li><li data-reveal=\"\"><strong>Open the theme menu (or any modal).</strong> Tab cycles within it. Escape closes it. Focus returns to the trigger.</li><li data-reveal=\"\"><strong>Submit the search form.</strong> Results render. Tab moves into the results list.</li></ol>\n<p>What to look for:</p>\n<ul>\n<li data-reveal=\"\"><strong>Invisible focus.</strong> A focus state that is not visible is a blocker.</li><li data-reveal=\"\"><strong>Focus order that jumps.</strong> Tab should move through the document in reading order. Jumps are usually a sign of <code>order: -1</code> in Flexbox without a matching <code>tabindex</code>.</li><li data-reveal=\"\"><strong>Interactive elements that are not focusable.</strong> A <code>&lt;div onclick&gt;</code> is a keyboard dead zone. Fix by replacing with <code>&lt;button&gt;</code>.</li><li data-reveal=\"\"><strong>Traps without escapes.</strong> Any overlay that does not close with Escape is a bug.</li></ul>\n<p>This pass catches almost everything axe missed. The findings are usually not \"my code is broken;\" they are \"my interaction model assumed a mouse.\"</p>\n<h2 id=\"pass-3-screen-reader\" tabindex=\"0\" data-toc-anchor=\"true\">Pass 3: screen reader</h2>\n<p>Time budget: 90 minutes.</p>\n<p>This is the uncomfortable pass. You have to turn on a screen reader and use the site for real. On macOS, that is VoiceOver. On Windows, NVDA (free) or JAWS (commercial). On iOS, VoiceOver. On Android, TalkBack.</p>\n<p>I use VoiceOver on macOS because it is built in and most of my readers on Apple devices use it. If the site works in VO, it usually works elsewhere, because the assistive APIs are fairly consistent.</p>\n<p>Basic VoiceOver commands:</p>\n<ul>\n<li data-reveal=\"\"><code>Cmd+F5</code> - toggle VoiceOver.</li><li data-reveal=\"\"><code>Ctrl+Option+arrow</code> - move through content.</li><li data-reveal=\"\"><code>Ctrl+Option+U</code> - open the Rotor, which navigates by headings, landmarks, links, etc.</li><li data-reveal=\"\"><code>Ctrl+Option+Space</code> - activate the current element.</li></ul>\n<p>The audit protocol:</p>\n<ol>\n<li data-reveal=\"\"><strong>Land on the home page with VO on.</strong> The page title is announced. The main nav is a landmark. The article list is readable.</li><li data-reveal=\"\"><strong>Open the Rotor with <code>Ctrl+Option+U</code> and navigate by landmarks.</strong> You should hear <code>banner</code>, <code>main</code>, <code>contentinfo</code>. No orphan landmarks. No missing landmarks.</li><li data-reveal=\"\"><strong>Navigate by headings.</strong> The heading tree should be correct - one <code>h1</code>, descending properly. If you hear a skip (\"h2 ... h4\"), that is a violation.</li><li data-reveal=\"\"><strong>Open a post.</strong> The title is read. The date is read (or skipped if decorative). The article content is navigable.</li><li data-reveal=\"\"><strong>Interact with the outline.</strong> Activating a link moves focus, and VO should announce the heading it landed on.</li><li data-reveal=\"\"><strong>Open the theme menu.</strong> VO announces \"dialog\" on open, the first focusable element, and the title.</li></ol>\n<p>The findings from this pass are qualitatively different. They are about announcement correctness and context. Things you might catch:</p>\n<ul>\n<li data-reveal=\"\">A date announced as a number rather than a date, because no <code>&lt;time&gt;</code> element was used.</li><li data-reveal=\"\">An icon button with an <code>aria-label</code> that reads awkwardly.</li><li data-reveal=\"\">A link that says \"Read more\" without context.</li><li data-reveal=\"\">A live region that does not announce because it was not <code>aria-live</code>.</li></ul>\n<p>These are the findings that automated tools cannot produce. They are also the ones that most improve the experience for actual screen reader users.</p>\n<h2 id=\"the-audit-log\" tabindex=\"0\" data-toc-anchor=\"true\">The audit log</h2>\n<p>I keep a simple markdown log per audit. Date, scope, findings, status.</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code># A11y audit - 2026-11-10\n\n## Scope\n\nHome, archive, a random post, search, about.\n\n## Findings\n\n- [x] Home: axe flagged color contrast on `.post-card__date` (3.9:1). Fixed.\n- [x] Post: Tab from outline link did not move focus into the section. Added `tabindex=\"-1\"` on headings. Fixed.\n- [ ] Search: VoiceOver does not announce result count change. `aria-live=\"polite\"` is set but on the wrong element. Open.\n- [x] Archive: focus ring invisible on pagination numbers in dark mode. Bumped outline-offset. Fixed.</code></pre><p>The log is a working document, not a report. I update it as I fix things. At the end of the audit, the open items are the follow-up tasks.</p>\n<h2 id=\"the-rhythm\" tabindex=\"0\" data-toc-anchor=\"true\">The rhythm</h2>\n<p>For dout.dev, this full audit happens every quarter. Short passes (axe only) run on every CI build. The quarterly cadence catches what CI cannot: semantic issues, new content patterns, regressions that axe does not model.</p>\n<h2 id=\"what-i-do-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not do</h2>\n<ul>\n<li data-reveal=\"\"><strong>Hire a professional audit for a personal blog.</strong> Worth it for a product. Overkill for this.</li><li data-reveal=\"\"><strong>Run a full WCAG 2.2 AAA pass.</strong> AAA is not a common benchmark and the incremental work is steep. AA is the target.</li><li data-reveal=\"\"><strong>Cover every browser and screen reader combination.</strong> VO on macOS and NVDA on Windows cover the cases I care about.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Accessibility auditing on a static blog is bounded work with clear protocols. Axe + keyboard + screen reader, in an afternoon, quarterly. The findings get better over time as the codebase gets better; the first audit is always the longest.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://github.com/dequelabs/axe-core?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">axe-core</a></li><li data-reveal=\"\"><a href=\"https://webaim.org/projects/screenreadersurvey/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WebAIM: Screen reader user survey</a></li><li data-reveal=\"\"><a href=\"https://support.apple.com/guide/voiceover/welcome/mac?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">VoiceOver for macOS - Apple docs</a></li><li data-reveal=\"\"><a href=\"https://www.nvaccess.org/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">NVDA screen reader</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/quickref/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.2 Quick Reference</a></li><li data-reveal=\"\"><a href=\"https://www.a11yproject.com/checklist/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">A11y Project checklist</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-23-manual-a11y-audit.png",
      "date_published": "2026-07-23T00:00:00.000Z",
      "tags": [
        "accessibility",
        "tooling"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-21-pnpm-workspaces-for-a-single-site.html",
      "url": "https://dout.dev/posts/2026-07-21-pnpm-workspaces-for-a-single-site.html",
      "title": "pnpm Workspaces for a Single Site: Needed or Not (Spoiler: It Depends)",
      "summary": "The question I keep getting",
      "content_html": "<h2 id=\"the-question-i-keep-getting\" tabindex=\"0\" data-toc-anchor=\"true\">The question I keep getting</h2>\n<p>\"Your repo has <code>pnpm-workspace.yaml</code>. Why? It is one site.\"</p>\n<p>Fair question. A workspace implies multiple packages that share tooling. A single site does not have multiple packages. On the face of it, <code>pnpm-workspace.yaml</code> in a single-site repo is overkill. Sometimes it is. On dout.dev it is not, and this post is the reasoning.</p>\n<h2 id=\"what-a-workspace-actually-costs\" tabindex=\"0\" data-toc-anchor=\"true\">What a workspace actually costs</h2>\n<p>Turning a repo into a pnpm workspace adds:</p>\n<ul>\n<li data-reveal=\"\">One file, <code>pnpm-workspace.yaml</code>, with the packages glob.</li><li data-reveal=\"\">The <code>workspace:</code> protocol for internal package references, if you use it.</li><li data-reveal=\"\">A mental model where <code>node_modules</code> is hoisted at the workspace root, with per-package exceptions.</li></ul>\n<p>That is all. No different commands. No different build time. The incremental cost is almost zero if you stop there.</p>\n<h2 id=\"what-it-lets-you-do-later\" tabindex=\"0\" data-toc-anchor=\"true\">What it lets you do later</h2>\n<p>Three things, each of which I expected to eventually need on dout.dev.</p>\n<p><strong>Extract a package.</strong> If the template engine, the CMS, or the syntax highlighter becomes useful outside this repo, it moves into <code>packages/pix-template-engine/</code> without restructuring anything. The workspace already knows how to build and test sub-packages.</p>\n<p><strong>Share dev tooling across packages.</strong> Biome config, Prettier config, TypeScript config, Playwright config - any of these can live at the root and be inherited by packages. A workspace makes this natural.</p>\n<p><strong>Run scripts across the graph.</strong> <code>pnpm -r test</code>, <code>pnpm -r build</code>, <code>pnpm -r lint</code>. If there are multiple packages, you get parallel execution and topological ordering for free.</p>\n<p>None of these is free-as-in-beer to retrofit. Adding a workspace to an established single-package repo means moving files, updating imports, reshuffling <code>package.json</code> entries, and fixing a week of small breakages. Starting with a workspace costs nothing and avoids that migration.</p>\n<h2 id=\"when-i-would-skip-it\" tabindex=\"0\" data-toc-anchor=\"true\">When I would skip it</h2>\n<p>On a throwaway project that will never be more than one package: skip it. On a learning project where the workspace concept itself is a distraction: skip it. The workspace is a bet on future-you wanting to extract something; if that bet is clearly wrong, do not place it.</p>\n<h2 id=\"the-actual-file\" tabindex=\"0\" data-toc-anchor=\"true\">The actual file</h2>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>packages:\n  - '.'</code></pre><p>That is it. The root itself is the one package in the workspace. Adding more later is a one-line change.</p>\n<p>Compare to the alternative where the repo grows a second package and you have to migrate on the spot. The migration is mechanical, not hard, but every \"mechanical\" task on a side project is a chance to stall. I prefer the inert one-line file.</p>\n<h2 id=\"the-hoisting-detail\" tabindex=\"0\" data-toc-anchor=\"true\">The hoisting detail</h2>\n<p>pnpm workspaces hoist shared dependencies to the root <code>node_modules</code>. That means:</p>\n<ul>\n<li data-reveal=\"\">Packages resolve common dependencies from the root, which reduces duplication and disk usage.</li><li data-reveal=\"\">Per-package versions are respected when they differ.</li><li data-reveal=\"\"><code>node_modules/.bin</code> at the root contains the CLIs from any package.</li></ul>\n<p>The hoisting is usually invisible and always correct. The one case where it is not invisible is when a tool reads <code>node_modules</code> directly and makes assumptions - older tools occasionally get confused. I have not hit this on dout.dev, but it is the reason some projects still prefer yarn 1 or npm with explicit workspaces.</p>\n<h2 id=\"per-package-package-json\" tabindex=\"0\" data-toc-anchor=\"true\">Per-package <code>package.json</code>?</h2>\n<p>Currently there is only the root <code>package.json</code>. If I extracted the template engine into <code>packages/pix-template-engine/</code>, it would have its own <code>package.json</code> with <code>name</code>, <code>version</code>, <code>exports</code>, and dependencies declared there. The main repo would reference it as <code>\"pix-template-engine\": \"workspace:*\"</code>.</p>\n<p>The <code>workspace:*</code> protocol means \"use the current version in the workspace, whatever that is.\" It is the feature that makes local development across packages painless - you do not <code>npm link</code>, you do not publish to a test registry. You just work across the tree.</p>\n<h2 id=\"monorepo-vs-workspace\" tabindex=\"0\" data-toc-anchor=\"true\">Monorepo vs workspace</h2>\n<p>These terms get conflated. A monorepo is a repository that contains multiple projects. A workspace is a package-manager feature that supports monorepos. You can have a monorepo without workspaces (you can have a monorepo without any package manager conventions at all, like the Linux kernel). You cannot really have workspaces without a monorepo - it would be pointless.</p>\n<p>For dout.dev, I currently have a monorepo-of-one, with workspace tooling ready for the day it becomes a monorepo-of-several.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A pnpm workspace on a single-site repo is cheap insurance. It costs nothing today and avoids a migration tomorrow. On a throwaway project, skip it. On a project you expect to live for years, the one-line <code>pnpm-workspace.yaml</code> is worth adding upfront.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://pnpm.io/workspaces?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">pnpm workspaces</a></li><li data-reveal=\"\"><a href=\"https://pnpm.io/workspaces?from=dout.dev#workspace-protocol-workspace\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">The <code>workspace:</code> protocol</a></li><li data-reveal=\"\"><a href=\"https://monorepo.tools/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Monorepo tooling comparison - monorepo.tools</a></li><li data-reveal=\"\"><a href=\"https://nx.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Nx</a> - if the workspace grows beyond pnpm-native capabilities</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-21-pnpm-workspaces-for-a-single-site.png",
      "date_published": "2026-07-21T00:00:00.000Z",
      "tags": [
        "tooling",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-18-core-web-vitals-in-production.html",
      "url": "https://dout.dev/posts/2026-07-18-core-web-vitals-in-production.html",
      "title": "Core Web Vitals in Production: LCP, CLS, INP on a Static Blog (Real Numbers, No Bullshit)",
      "summary": "The three numbers that matter",
      "content_html": "<h2 id=\"the-three-numbers-that-matter\" tabindex=\"0\" data-toc-anchor=\"true\">The three numbers that matter</h2>\n<p>Core Web Vitals are not a complete performance model. They are three numbers Google decided matter for user experience, and they correlate strongly with perceived quality. For a content site, the three are:</p>\n<ul>\n<li data-reveal=\"\"><strong>LCP (Largest Contentful Paint)</strong> - how long until the main element of the page appears. Target: under 2.5s.</li><li data-reveal=\"\"><strong>CLS (Cumulative Layout Shift)</strong> - how much the layout jumps during load. Target: under 0.1.</li><li data-reveal=\"\"><strong>INP (Interaction to Next Paint)</strong> - how responsive the page feels on click or keystroke. Target: under 200ms.</li></ul>\n<p>On dout.dev the live numbers are well under target on desktop and comfortably under on mobile. This post is what made that achievable, with the specific choices that matter.</p>\n<h2 id=\"lcp-the-image-the-font-and-the-handful-of-milliseconds-that-add-up\" tabindex=\"0\" data-toc-anchor=\"true\">LCP: the image, the font, and the handful of milliseconds that add up</h2>\n<p>For a blog, the LCP element is almost always the post cover image or the first heading. Two decisions dominate.</p>\n<p><strong>The cover image is eager and high-priority.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img\n  src=\"/assets/images/cover.jpg\"\n  alt=\"...\"\n  width=\"1200\"\n  height=\"630\"\n  loading=\"eager\"\n  fetchpriority=\"high\"\n  decoding=\"async\"\n/&gt;</code></pre><p>Default <code>loading=\"lazy\"</code> on the LCP image delays the one number that most affects the score. <code>fetchpriority=\"high\"</code> moves the image up in the network queue. Both are opt-in changes from the default behavior, and both are measurable.</p>\n<p><strong>The font that renders the LCP text is preloaded.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"preload\" as=\"font\" type=\"font/woff2\" href=\"/assets/fonts/Inter-Bold.woff2\" crossorigin /&gt;</code></pre><p>Without preload, the font is discovered when the CSS parses the <code>@font-face</code> rule, which is at least one round trip later. Preloading the weight used in the LCP heading saves a round trip and prevents the \"invisible text\" flash while the font arrives.</p>\n<p><strong>No render-blocking third-party resources above the fold.</strong> No Google Fonts link, no analytics script in the head, no \"quick chat widget\" that loads before the page. Every third-party script on dout.dev loads after the main content, or not at all on the critical path.</p>\n<h2 id=\"cls-known-dimensions-everywhere\" tabindex=\"0\" data-toc-anchor=\"true\">CLS: known dimensions, everywhere</h2>\n<p>Layout shift is mostly a function of elements arriving after layout is computed. Four rules cover 95% of it.</p>\n<p><strong>Every image has <code>width</code> and <code>height</code>.</strong> The post generator reads the image manifest and emits the attributes. The browser reserves the box at the correct aspect ratio before the pixels arrive.</p>\n<p><strong>Web fonts do not shift layout.</strong> <code>font-display: swap</code> uses a fallback while the web font loads, and the fallback is chosen to have similar metrics. The metric pair I use for Inter is <code>Arial</code> with <code>size-adjust</code> and <code>ascent-override</code> tuned:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>@font-face {\n  font-family: 'Inter Fallback';\n  src: local('Arial');\n  size-adjust: 107%;\n  ascent-override: 90%;\n}\n\nbody {\n  font-family: 'Inter', 'Inter Fallback', sans-serif;\n}</code></pre><p>When Inter loads, the text does not visibly shift because the fallback is already sized to match. This is a handful of CSS lines that prevent an entire class of \"text jumped when font arrived\" bugs.</p>\n<p><strong>Embedded iframes have reserved space.</strong> The Giscus comments shell has a minimum height before the iframe loads. When the iframe arrives and reports its real height, the shell expands downward, which does not affect layout above it.</p>\n<p><strong>Dynamically injected content pushes nothing.</strong> The one place that loads content dynamically on dout.dev is the comments. Because they are at the bottom of the page, they cannot shift layout above them.</p>\n<h2 id=\"inp-small-main-thread-no-blocking-handlers\" tabindex=\"0\" data-toc-anchor=\"true\">INP: small main thread, no blocking handlers</h2>\n<p>INP replaced FID as the responsiveness metric because FID only measured the first interaction. INP measures every interaction and reports the 75th percentile.</p>\n<p>Three decisions keep it low.</p>\n<p><strong>The main thread is small.</strong> Total JavaScript on a post page is under 10KB gzipped. No framework runtime, no hydration, no dev-mode analytics agent. The JS that runs is the theme switcher, the scrollspy, the lazy-load observer, and the search initializer. Each is small and non-blocking.</p>\n<p><strong>Event handlers are short.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>document.addEventListener('click', (event) =&gt; {\n  const toggle = event.target.closest('.theme-toggle');\n  if (toggle) handleThemeToggle();\n});</code></pre><p>A delegated click handler is cheaper than 20 individual ones. The work inside each handler is under 5ms on a mid-tier phone.</p>\n<p><strong>No synchronous layout thrashing.</strong> When the color-scheme changes, I write to <code>document.documentElement.dataset.colorScheme</code>, which sets an attribute. The browser recomputes styles once on the next animation frame. No forced layout, no <code>offsetWidth</code> reads in a loop.</p>\n<h2 id=\"measuring-it-in-production\" tabindex=\"0\" data-toc-anchor=\"true\">Measuring it in production</h2>\n<p>Lab measurements (Lighthouse) are useful for finding regressions. Field measurements (real users) are the ones that actually count for SEO. For a small site without a backend, the options are:</p>\n<ul>\n<li data-reveal=\"\"><strong>Chrome User Experience Report (CrUX)</strong> - Google publishes aggregate field data for origins with enough traffic. Check your site on PageSpeed Insights; if \"Real user experience\" appears, CrUX has data.</li><li data-reveal=\"\"><strong>Web Vitals JS library</strong> - ship a small script that posts LCP, CLS, INP to your analytics. On dout.dev the analytics endpoint is a simple POST that records page hits and metrics without cookies.</li></ul>\n<p>The library is under 3KB gzipped and the handler is short:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { onCLS, onLCP, onINP } from 'web-vitals';\n\nfunction send(metric) {\n  navigator.sendBeacon('/metrics', JSON.stringify(metric));\n}\n\nonLCP(send);\nonCLS(send);\nonINP(send);</code></pre><p>Collecting field vitals for a small blog is optional. Collecting them is how you catch regressions that lab tests miss - device variability, network variability, the long tail of \"users on 3G in a basement.\"</p>\n<h2 id=\"the-cost-i-did-not-pay\" tabindex=\"0\" data-toc-anchor=\"true\">The cost I did not pay</h2>\n<ul>\n<li data-reveal=\"\"><strong>No SSR framework.</strong> A static site is already pre-rendered. Adding SSR on top is a performance anti-pattern for content sites.</li><li data-reveal=\"\"><strong>No runtime optimization service.</strong> No image CDN resizing on the fly, no edge function transforming HTML. The file you serve is the file you built.</li><li data-reveal=\"\"><strong>No aggressive code splitting.</strong> The JS is small enough that splitting adds overhead without meaningful benefit.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Core Web Vitals on a static blog are a solved problem if you make the right small choices: eager LCP image, font preload, no third-party critical-path scripts, known image dimensions, font metric matching, a small main thread. Each is a specific, bounded decision. None requires a framework.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://web.dev/articles/vitals?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Core Web Vitals - web.dev</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/optimize-lcp?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize LCP</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/optimize-cls?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize CLS</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/optimize-inp?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize INP</a></li><li data-reveal=\"\"><a href=\"https://github.com/GoogleChrome/web-vitals?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>web-vitals</code> JavaScript library</a></li><li data-reveal=\"\"><a href=\"https://developer.chrome.com/docs/crux?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Chrome User Experience Report</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/font-fallbacks?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Font metric matching - web.dev</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-18-core-web-vitals-in-production.png",
      "date_published": "2026-07-18T00:00:00.000Z",
      "tags": [
        "performance",
        "seo",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-14-honest-focus-traps-escape.html",
      "url": "https://dout.dev/posts/2026-07-14-honest-focus-traps-escape.html",
      "title": "Honest Focus Traps and Escape: Keyboard-First Menus (Without Locking Users In)",
      "summary": "The misconception",
      "content_html": "<h2 id=\"the-misconception\" tabindex=\"0\" data-toc-anchor=\"true\">The misconception</h2>\n<p>\"Focus trap\" has a bad reputation because most implementations get it wrong. A trap that is actually a prison - where the user cannot escape with Escape, cannot close with a click outside, and cannot tell that they are trapped - is a bug.</p>\n<p>A focus trap done right is a kindness. It says: \"while this overlay is open, Tab cycles within it. When you close it, focus returns where it came from. Escape works. Outside click works.\"</p>\n<p>On dout.dev the mobile menu, the search dialog, and any future modal follow the same rules. This post is those rules, with code.</p>\n<h2 id=\"when-a-focus-trap-is-appropriate\" tabindex=\"0\" data-toc-anchor=\"true\">When a focus trap is appropriate</h2>\n<p>Two criteria, both required.</p>\n<ol>\n<li data-reveal=\"\"><strong>The overlay is modal.</strong> The user is expected to interact with it or dismiss it before doing anything else. A non-modal popover - a tooltip, an inline disclosure - should not trap focus.</li><li data-reveal=\"\"><strong>The overlay visually blocks the rest of the page.</strong> A full-screen dialog, a slide-in panel, a menu that darkens the page behind it.</li></ol>\n<p>If either is false, do not add a trap. A tooltip trap is a bug.</p>\n<h2 id=\"the-five-rules-of-a-good-trap\" tabindex=\"0\" data-toc-anchor=\"true\">The five rules of a good trap</h2>\n<p><strong>Rule 1: remember where focus was.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>let previouslyFocused = null;\n\nfunction openOverlay(overlay) {\n  previouslyFocused = document.activeElement;\n  // ...\n}\n\nfunction closeOverlay(overlay) {\n  overlay.hidden = true;\n  previouslyFocused?.focus();\n  previouslyFocused = null;\n}</code></pre><p>When the overlay closes, focus returns to the element that opened it. Without this, focus goes to <code>document.body</code>, and the next Tab jumps to the top of the page. Disorienting.</p>\n<p><strong>Rule 2: move focus into the overlay on open.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function openOverlay(overlay) {\n  previouslyFocused = document.activeElement;\n  overlay.hidden = false;\n\n  const firstFocusable = overlay.querySelector(\n    'a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\"-1\"])'\n  );\n  firstFocusable?.focus();\n}</code></pre><p>The first interactive element inside the overlay receives focus. Screen readers announce it. Keyboard users are immediately in context.</p>\n<p><strong>Rule 3: cycle Tab within the overlay.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function trapFocus(event, overlay) {\n  if (event.key !== 'Tab') return;\n\n  const focusables = overlay.querySelectorAll(\n    'a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\"-1\"])'\n  );\n  if (focusables.length === 0) return;\n\n  const first = focusables[0];\n  const last = focusables[focusables.length - 1];\n\n  if (event.shiftKey &amp;&amp; document.activeElement === first) {\n    event.preventDefault();\n    last.focus();\n  } else if (!event.shiftKey &amp;&amp; document.activeElement === last) {\n    event.preventDefault();\n    first.focus();\n  }\n}</code></pre><p>Tabbing past the last element wraps to the first. Shift+Tab past the first wraps to the last. Anywhere in between, default Tab behavior.</p>\n<p><strong>Rule 4: Escape closes.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function handleKeydown(event, overlay) {\n  if (event.key === 'Escape') {\n    event.preventDefault();\n    closeOverlay(overlay);\n    return;\n  }\n  trapFocus(event, overlay);\n}</code></pre><p>Universal expectation. Not optional.</p>\n<p><strong>Rule 5: outside click closes.</strong></p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>overlay.addEventListener('click', (event) =&gt; {\n  if (event.target === overlay) {\n    // Click on the backdrop, not a child\n    closeOverlay(overlay);\n  }\n});</code></pre><p>If the overlay is a full-screen layer with a visible backdrop, clicking the backdrop is a valid close affordance. Screen reader users have Escape; pointer users have click-outside.</p>\n<h2 id=\"putting-it-together\" tabindex=\"0\" data-toc-anchor=\"true\">Putting it together</h2>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>class Overlay {\n  constructor(root, { onClose } = {}) {\n    this.root = root;\n    this.onClose = onClose;\n    this.previouslyFocused = null;\n    this.handleKeydown = this.handleKeydown.bind(this);\n    this.handleClick = this.handleClick.bind(this);\n  }\n\n  open() {\n    this.previouslyFocused = document.activeElement;\n    this.root.hidden = false;\n    const first = this.getFocusables()[0];\n    first?.focus();\n    document.addEventListener('keydown', this.handleKeydown);\n    this.root.addEventListener('click', this.handleClick);\n  }\n\n  close() {\n    this.root.hidden = true;\n    document.removeEventListener('keydown', this.handleKeydown);\n    this.root.removeEventListener('click', this.handleClick);\n    this.previouslyFocused?.focus();\n    this.previouslyFocused = null;\n    this.onClose?.();\n  }\n\n  handleKeydown(event) {\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      this.close();\n      return;\n    }\n    if (event.key !== 'Tab') return;\n\n    const focusables = this.getFocusables();\n    if (focusables.length === 0) return;\n\n    const first = focusables[0];\n    const last = focusables[focusables.length - 1];\n\n    if (event.shiftKey &amp;&amp; document.activeElement === first) {\n      event.preventDefault();\n      last.focus();\n    } else if (!event.shiftKey &amp;&amp; document.activeElement === last) {\n      event.preventDefault();\n      first.focus();\n    }\n  }\n\n  handleClick(event) {\n    if (event.target === this.root) this.close();\n  }\n\n  getFocusables() {\n    return this.root.querySelectorAll('a[href], button:not([disabled]), input, [tabindex]:not([tabindex=\"-1\"])');\n  }\n}</code></pre><p>60 lines, reusable, no framework. Every overlay on dout.dev uses it.</p>\n<h2 id=\"the-aria-that-the-component-needs\" tabindex=\"0\" data-toc-anchor=\"true\">The ARIA that the component needs</h2>\n<p>The overlay element itself needs the right roles and attributes, not just the JS.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;div class=\"overlay\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"overlay-title\" hidden&gt;\n  &lt;h2 id=\"overlay-title\"&gt;Menu&lt;/h2&gt;\n  &lt;!-- ... --&gt;\n&lt;/div&gt;</code></pre><ul>\n<li data-reveal=\"\"><code>role=\"dialog\"</code> tells assistive tech this is a modal.</li><li data-reveal=\"\"><code>aria-modal=\"true\"</code> indicates that the rest of the page is inert while the dialog is open.</li><li data-reveal=\"\"><code>aria-labelledby</code> points to the visible title so screen readers announce it when focus moves in.</li><li data-reveal=\"\"><code>hidden</code> is the default state; JS toggles it.</li></ul>\n<p>Without these, the JS trap works for keyboard users but screen readers do not understand that they are in a modal context. Both layers have to agree.</p>\n<h2 id=\"what-i-do-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not do</h2>\n<ul>\n<li data-reveal=\"\"><strong>Focus-within timing hacks.</strong> <code>setTimeout(focus, 0)</code> is almost always a sign that the DOM is not ready. Fix the timing, not the symptom.</li><li data-reveal=\"\"><strong>Overscroll lock via <code>body { overflow: hidden }</code>.</strong> Sometimes useful, often jarring. On dout.dev the overlays are full-screen so scroll lock is redundant.</li><li data-reveal=\"\"><strong>Inert polyfills.</strong> The <code>inert</code> attribute on the background content is still not uniformly supported. <code>aria-modal=\"true\"</code> is enough for current assistive tech.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A focus trap is not the JavaScript pattern. It is the contract: remember where focus was, move focus to the overlay, cycle Tab within it, honor Escape, honor outside click. Sixty lines of code, five rules, one reusable class. Every modal on a well-built site follows the same shape.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WAI-ARIA Authoring Practices: Dialog (Modal) Pattern</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/inert?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Inert - MDN</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.1.1 Keyboard</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.4.3 Focus Order</a></li><li data-reveal=\"\"><a href=\"https://github.com/focus-trap/focus-trap?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">focus-trap (library)</a> - if you prefer a ready-made option</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-14-honest-focus-traps-escape.png",
      "date_published": "2026-07-14T00:00:00.000Z",
      "tags": [
        "accessibility",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-11-strict-csp-self-hosted-fonts.html",
      "url": "https://dout.dev/posts/2026-07-11-strict-csp-self-hosted-fonts.html",
      "title": "Strict CSP + Self-Hosted Fonts: unsafe-inline Is a Bad Friend (And I Will Die on This Hill)",
      "summary": "The claim",
      "content_html": "<h2 id=\"the-claim\" tabindex=\"0\" data-toc-anchor=\"true\">The claim</h2>\n<p>Most sites ship a Content Security Policy that is effectively \"please stop yelling at me.\" A policy full of <code>unsafe-inline</code>, <code>*</code>, and wildcard sources is better than no policy, but not by much. A strict CSP is achievable on a modern static blog, it stops a large class of attacks dead, and it forces a few discipline improvements that pay off in other places.</p>\n<p>On dout.dev the policy is strict enough to block an inline <code>&lt;script&gt;</code> I might write by accident. This post is the policy, the font story that enabled it, and the three gotchas I hit getting there.</p>\n<h2 id=\"the-current-policy\" tabindex=\"0\" data-toc-anchor=\"true\">The current policy</h2>\n<p>Emitted via HTTP headers in production. Also mirrored as a <code>&lt;meta&gt;</code> tag for local preview (with one adjustment - <code>upgrade-insecure-requests</code> belongs in headers, not meta).</p>\n<pre is=\"pix-highlighter\"><code>Content-Security-Policy:\n  default-src 'self';\n  base-uri 'self';\n  form-action 'self';\n  object-src 'none';\n  img-src 'self' data: https:;\n  font-src 'self';\n  media-src 'self';\n  manifest-src 'self';\n  worker-src 'self';\n  script-src 'self' 'unsafe-inline' https://giscus.app;\n  style-src 'self' 'unsafe-inline';\n  connect-src 'self' https://giscus.app;\n  frame-src https://giscus.app https://codepen.io;\n  upgrade-insecure-requests</code></pre><p>Tight, but pragmatic. Three concessions and the reasons for each.</p>\n<p><strong><code>'unsafe-inline'</code> in <code>script-src</code>.</strong> Two inline scripts survive in the head: the theme pre-paint resolver and the file-preview banner. Neither accepts user input. Both could be moved to nonce-based CSP, but for a blog with a single deployment origin the cost of a nonce (rewriting the build to inject a fresh nonce per response) exceeds the benefit.</p>\n<p><strong><code>'unsafe-inline'</code> in <code>style-src</code>.</strong> A few critical CSS declarations are inlined for performance (the file-preview banner, the theme bootstrap). Same reasoning. For a team product I would invest in a nonce; for a blog I did not.</p>\n<p><strong><code>https://giscus.app</code> allowed on <code>script-src</code>, <code>connect-src</code>, <code>frame-src</code>.</strong> Giscus is a third-party comment system. It runs a script in the page, connects back to its own domain, and embeds an iframe. All three are required for the feature to work.</p>\n<p>Everything else is <code>'self'</code> or <code>'none'</code>. No CDNs, no third-party analytics, no embed farms.</p>\n<h2 id=\"the-font-story-that-made-font-src-self-possible\" tabindex=\"0\" data-toc-anchor=\"true\">The font story that made <code>font-src 'self'</code> possible</h2>\n<p>A common CSP mistake is leaving <code>font-src</code> wide-open because \"we use Google Fonts.\" The fix is to stop using Google Fonts as a third-party dependency and serve the fonts from your own origin.</p>\n<p>The practical benefit is not just security. It is performance: one fewer DNS lookup, one fewer TLS handshake, one fewer cross-origin preconnect. For the LCP on a content-heavy page, that is measurable.</p>\n<p>The mechanics:</p>\n<ol>\n<li data-reveal=\"\">Download the font files (WOFF2 and optionally WOFF) from the source (Google Fonts has a download button; Inter has a direct distribution; every good font does).</li><li data-reveal=\"\">Place them in <code>src/assets/fonts/</code>.</li><li data-reveal=\"\">Declare <code>@font-face</code> rules pointing at the local files.</li><li data-reveal=\"\">Preload the critical weight so the browser starts the font download early.</li></ol>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>@font-face {\n  font-family: 'Inter';\n  font-style: normal;\n  font-weight: 400;\n  font-display: swap;\n  src: url('/assets/fonts/Inter-Regular.woff2') format('woff2');\n}\n\n@font-face {\n  font-family: 'Inter';\n  font-style: normal;\n  font-weight: 700;\n  font-display: swap;\n  src: url('/assets/fonts/Inter-Bold.woff2') format('woff2');\n}</code></pre><pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"preload\" as=\"font\" type=\"font/woff2\" href=\"/assets/fonts/Inter-Regular.woff2\" crossorigin /&gt;</code></pre><p><code>font-display: swap</code> tells the browser to use the fallback font until the custom one loads. It prevents the \"invisible text flash\" that <code>font-display: block</code> can cause.</p>\n<p>The <code>crossorigin</code> on the preload is required even for same-origin fonts. Without it, the browser downloads the font twice - once for the preload, once for the actual request - because the cache key differs. One attribute, a real performance win.</p>\n<h2 id=\"the-three-gotchas\" tabindex=\"0\" data-toc-anchor=\"true\">The three gotchas</h2>\n<p>The strict policy broke three things before it worked. Each is worth calling out.</p>\n<h3 id=\"1-inline-event-handlers-are-not-allowed\" tabindex=\"0\" data-toc-anchor=\"true\">1. Inline event handlers are not allowed</h3>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;!-- Breaks under strict CSP --&gt;\n&lt;button onclick=\"doThing()\"&gt;Click&lt;/button&gt;</code></pre><p>Every inline <code>on*</code> handler in the codebase got refactored into <code>addEventListener</code> calls in a shared script. Not hard, but easy to miss - and the browser console tells you exactly where the CSP violated, so it is a mechanical cleanup.</p>\n<h3 id=\"2-data-images-are-commonly-blocked\" tabindex=\"0\" data-toc-anchor=\"true\">2. <code>data:</code> images are commonly blocked</h3>\n<pre is=\"pix-highlighter\"><code>img-src 'self' data: https:;</code></pre><p>The <code>data:</code> source is explicitly listed because inline SVGs and base64-encoded small images (favicons, icons) use <code>data:</code> URIs. Without that, the placeholders break.</p>\n<p><code>https:</code> is included because external cover images reference HTTPS URLs. Tight enough to keep out <code>http:</code> and <code>file:</code>; permissive enough to not break the CMS when a post links an external image.</p>\n<h3 id=\"3-the-service-worker-needs-worker-src-self\" tabindex=\"0\" data-toc-anchor=\"true\">3. The service worker needs <code>worker-src 'self'</code></h3>\n<p>Omitted from the policy, the service worker fails to register and there is nothing in the console that clearly says why. <code>worker-src 'self'</code> makes it work.</p>\n<h2 id=\"csp-violation-reporting-for-when-it-breaks-later\" tabindex=\"0\" data-toc-anchor=\"true\">CSP violation reporting, for when it breaks later</h2>\n<p>The policy will break something in the future. A new feature, a new library, a copy-pasted embed. The way to find out is to wire up a CSP report endpoint.</p>\n<pre is=\"pix-highlighter\"><code>Content-Security-Policy:\n  ...;\n  report-to csp-endpoint;\n  report-uri /csp-report;</code></pre><p>On a static site there is no endpoint to receive the reports. I use <code>report-uri.com</code> or a simple edge function to receive violations. The first time something breaks in production, the report arrives before the complaint does.</p>\n<h2 id=\"what-a-strict-csp-actually-stops\" tabindex=\"0\" data-toc-anchor=\"true\">What a strict CSP actually stops</h2>\n<ul>\n<li data-reveal=\"\"><strong>Cross-site script injection</strong> from comments or reflected input. With <code>'self'</code> on scripts, an injected <code>&lt;script src=\"evil.com/x.js\"&gt;</code> does not execute.</li><li data-reveal=\"\"><strong>Clickjacking</strong> via framing. <code>frame-ancestors 'none'</code> (not in the policy above, worth adding) plus the appropriate <code>X-Frame-Options</code> header stops the page from being embedded.</li><li data-reveal=\"\"><strong>Form-hijacking.</strong> <code>form-action 'self'</code> means a forged form cannot POST to an attacker's domain.</li><li data-reveal=\"\"><strong>Accidental third-party pixel tracking.</strong> If a future me copy-pastes a tracking snippet, it is blocked at the CSP layer before it phones home.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A strict CSP on a modern static site is achievable and not exotic. The enabling moves are serving fonts from the same origin, removing inline event handlers, and being honest about the third-party embeds you need. Everything else is <code>'self'</code> or <code>'none'</code>.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://content-security-policy.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Content Security Policy Reference - content-security-policy.com</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSP on MDN</a></li><li data-reveal=\"\"><a href=\"https://css-tricks.com/snippets/css/using-font-face-in-css/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Self-hosting Google Fonts - CSS-Tricks</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>font-display</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://csp-evaluator.withgoogle.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSP Evaluator - Google</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-11-strict-csp-self-hosted-fonts.png",
      "date_published": "2026-07-11T00:00:00.000Z",
      "tags": [
        "security",
        "performance",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-07-dark-mode-accent-prefers-color-scheme.html",
      "url": "https://dout.dev/posts/2026-07-07-dark-mode-accent-prefers-color-scheme.html",
      "title": "Dark Mode, Accent Color, `prefers-color-scheme`: the Theme Switcher From Scratch",
      "summary": "The feature, and the three traps",
      "content_html": "<h2 id=\"the-feature-and-the-three-traps\" tabindex=\"0\" data-toc-anchor=\"true\">The feature, and the three traps</h2>\n<p>A theme switcher looks simple. Light mode, dark mode, auto. Maybe an accent color. In practice it has three common bugs I see in almost every implementation.</p>\n<ol>\n<li data-reveal=\"\"><strong>Flash of wrong theme.</strong> The page loads in light, the script runs, the theme swaps to dark. For a fraction of a second the user sees the wrong colors.</li><li data-reveal=\"\"><strong>System preference ignored after a manual override.</strong> The user picks \"dark,\" closes the tab, comes back later. The site is still dark even though the user has since switched their OS to light. That is correct. The inverse bug: user picked \"auto,\" OS switches to dark, the site stays light because the script does not listen for the change.</li><li data-reveal=\"\"><strong>Preference lost on refresh.</strong> The theme was in memory, not persisted.</li></ol>\n<p>The switcher on dout.dev addresses each explicitly. The code is under 50 lines.</p>\n<h2 id=\"the-token-architecture-it-relies-on\" tabindex=\"0\" data-toc-anchor=\"true\">The token architecture it relies on</h2>\n<p>The switcher only works because the CSS is already token-driven. Every component reads semantic tokens; semantic tokens are defined on <code>:root</code> and overridden per theme.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --color-bg: var(--surface-1);\n  --color-fg: var(--text-primary);\n  --color-accent: #ff6b3d;\n}\n\n[data-color-scheme='light'] {\n  --color-bg: #fafafa;\n  --color-fg: #1a1a1a;\n}\n\n[data-color-scheme='dark'] {\n  --color-bg: #0b0b0f;\n  --color-fg: #e7e7ef;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root:not([data-color-scheme]) {\n    --color-bg: #0b0b0f;\n    --color-fg: #e7e7ef;\n  }\n}</code></pre><p>The rules:</p>\n<ul>\n<li data-reveal=\"\"><code>:root</code> defines the defaults (the \"auto\" case without system preference).</li><li data-reveal=\"\"><code>[data-color-scheme='light']</code> and <code>[data-color-scheme='dark']</code> override for explicit choices.</li><li data-reveal=\"\">The <code>@media (prefers-color-scheme: dark)</code> block kicks in only when no explicit <code>data-color-scheme</code> is set - the <code>:not([data-color-scheme])</code> selector - so user choice always wins over system preference.</li></ul>\n<h2 id=\"no-flash-of-wrong-theme\" tabindex=\"0\" data-toc-anchor=\"true\">No flash of wrong theme</h2>\n<p>The browser must know the theme before it paints the first frame. That means the theme resolution happens in an inline script in the <code>&lt;head&gt;</code>, before any CSS that depends on it.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;script&gt;\n  (function () {\n    try {\n      var stored = localStorage.getItem('color-scheme');\n      var stored_accent = localStorage.getItem('accent');\n      if (stored === 'light' || stored === 'dark') {\n        document.documentElement.dataset.colorScheme = stored;\n      }\n      if (stored_accent) {\n        document.body.dataset.accent = stored_accent;\n      }\n    } catch (_) {\n      /* localStorage unavailable */\n    }\n  })();\n&lt;/script&gt;</code></pre><p>This script is blocking and synchronous and that is exactly what you want. It runs before the body parses, sets the attribute, and the initial CSS cascade applies the correct theme before paint.</p>\n<p>If the user has never made a choice, no attribute is set, and the <code>prefers-color-scheme</code> media query rules. That is the \"auto\" path.</p>\n<h2 id=\"the-toggle-button\" tabindex=\"0\" data-toc-anchor=\"true\">The toggle button</h2>\n<p>The toggle is a button that cycles Auto → Dark → Light → Auto. The current state is mirrored to <code>aria-pressed</code> or announced via text for screen readers.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;button class=\"theme-toggle\" aria-label=\"Change theme\"&gt;\n  &lt;span class=\"theme-toggle__label\"&gt;Auto&lt;/span&gt;\n&lt;/button&gt;</code></pre><pre is=\"pix-highlighter\" data-lang=\"js\"><code>const toggle = document.querySelector('.theme-toggle');\nconst label = toggle.querySelector('.theme-toggle__label');\nconst order = [null, 'dark', 'light'];\n\nfunction setTheme(value) {\n  if (value === null) {\n    delete document.documentElement.dataset.colorScheme;\n    localStorage.removeItem('color-scheme');\n    label.textContent = 'Auto';\n  } else {\n    document.documentElement.dataset.colorScheme = value;\n    localStorage.setItem('color-scheme', value);\n    label.textContent = value === 'dark' ? 'Dark' : 'Light';\n  }\n}\n\ntoggle.addEventListener('click', () =&gt; {\n  const current = document.documentElement.dataset.colorScheme ?? null;\n  const next = order[(order.indexOf(current) + 1) % order.length];\n  setTheme(next);\n});\n\nconst currentOnLoad = document.documentElement.dataset.colorScheme ?? null;\nlabel.textContent = currentOnLoad === null ? 'Auto' : currentOnLoad === 'dark' ? 'Dark' : 'Light';</code></pre><p>The initial <code>label.textContent</code> assignment is subtle: on page load, the inline script has already applied the theme, so we read back from the DOM to render the correct label.</p>\n<h2 id=\"listening-for-system-preference-changes\" tabindex=\"0\" data-toc-anchor=\"true\">Listening for system preference changes</h2>\n<p>In \"auto\" mode, when the OS switches theme, the site should update live. The <code>matchMedia</code> API emits change events:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const media = matchMedia('(prefers-color-scheme: dark)');\nmedia.addEventListener('change', () =&gt; {\n  // The CSS handles the color change automatically via the media query.\n  // Nothing for us to do - the media query in CSS observes the same source.\n});</code></pre><p>Actually, nothing for us to do. The CSS already responds to the system change; this event is only useful if you want to mirror the state somewhere in the UI. If the toggle's label shows \"Auto,\" the visible state on screen updates naturally through CSS.</p>\n<h2 id=\"accent-color-picker\" tabindex=\"0\" data-toc-anchor=\"true\">Accent color picker</h2>\n<p>Accent is orthogonal to light/dark. A user can pick a dark theme with a green accent or a light theme with a violet accent. The primary accent is a separate attribute on <code>body</code>.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>body[data-accent='violet'] {\n  --color-accent: #8b5cf6;\n}\nbody[data-accent='green'] {\n  --color-accent: #22c55e;\n}</code></pre><p>The picker is a radio group or a row of swatches. Selection writes to <code>body[data-accent]</code> and persists to <code>localStorage</code>.</p>\n<p>The one detail that makes this land: the CSS <code>--color-accent</code> is referenced by derived tokens (like <code>--color-accent-soft</code> via <code>color-mix()</code>), so the whole accent-tinted surface updates in one paint.</p>\n<h2 id=\"syncing-the-giscus-theme\" tabindex=\"0\" data-toc-anchor=\"true\">Syncing the Giscus theme</h2>\n<p>Giscus is an iframe, so its theme does not inherit from the parent page CSS. The page has to postMessage the theme into the iframe whenever it changes.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function syncGiscusTheme(theme) {\n  const frame = document.querySelector('iframe.giscus-frame');\n  if (!frame) return;\n  const giscusTheme = theme === 'dark' ? 'dark' : 'light';\n  frame.contentWindow?.postMessage({ giscus: { setConfig: { theme: giscusTheme } } }, 'https://giscus.app');\n}</code></pre><p>A MutationObserver on <code>document.documentElement</code> watches for <code>data-color-scheme</code> changes and calls <code>syncGiscusTheme</code>. That keeps comments visually in sync with the rest of the page. Without this, switching from dark to light leaves the comments in a contrast mismatch.</p>\n<h2 id=\"keyboard-focus-motion\" tabindex=\"0\" data-toc-anchor=\"true\">Keyboard, focus, motion</h2>\n<p>The toggle is a real <code>&lt;button&gt;</code>, which means it is keyboard-focusable by default. Its focus ring uses <code>:focus-visible</code>, and clicking it does not steal focus.</p>\n<p>For users with <code>prefers-reduced-motion: reduce</code>, the accent color transition is disabled. That is a two-line CSS rule:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>body {\n  transition:\n    background-color 0.2s,\n    color 0.2s;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  body {\n    transition: none;\n  }\n}</code></pre><h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A theme switcher that does not flash, respects the system, persists the user's choice, and syncs third-party iframes is about 50 lines of code and one well-structured CSS layer. The trick is the inline pre-paint script and the attribute-plus-media-query architecture. Neither requires a framework.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>prefers-color-scheme</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>matchMedia</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/color-scheme?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Theme switcher without FART - web.dev</a></li><li data-reveal=\"\"><a href=\"https://giscus.app/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Giscus customization API</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>color-mix()</code> - MDN</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-07-dark-mode-accent-prefers-color-scheme.png",
      "date_published": "2026-07-07T00:00:00.000Z",
      "tags": [
        "design-systems",
        "css",
        "accessibility"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-05-css-properties-hierarchy.html",
      "url": "https://dout.dev/posts/2026-07-05-css-properties-hierarchy.html",
      "title": "CSS Properties Hierarchy (Or: How I Learned Custom Properties Can Replace Preprocessors)",
      "summary": "Context - The chaos of unorganized CSS",
      "content_html": "<h2 id=\"context-the-chaos-of-unorganized-css\" tabindex=\"0\" data-toc-anchor=\"true\">Context - The chaos of unorganized CSS</h2>\n<p>I've been writing CSS since before <code>border-radius</code> was a thing, back when we used <code>-moz-border-radius</code> and prayed everything to work well.</p>\n<p>I've seen preprocessors rise (LESS, Sass, Stylus, the holy war... what a mess) and watched CSS itself absorb every feature they offered, one spec at a time.</p>\n<p>And through all of it, one thing never changed: the absolute chaos of an unorganized ruleset.</p>\n<p>We all know about this: 400 lines of CSS where <code>color: red</code> is followed by <code>position: absolute</code> followed by three vendor-prefixed gradients followed by a <code>font-size</code> that overrides the one fifty lines up. It's not wrong, the cascade still resolves it, the browser still paints it, but it's <em>unnecessary friction</em>.</p>\n<p>Every time you touch that file, you waste a few seconds scanning for what you need. Those seconds add up to hours. Those hours add up to bugs.</p>\n<p>So I developed a system. A hierarchy that mirrors the browser's own rendering pipeline: <strong>outside-in, layout-first, paint-last</strong>.</p>\n<h2 id=\"the-hierarchy-every-ruleset-tells-a-story\" tabindex=\"0\" data-toc-anchor=\"true\">The hierarchy - every ruleset tells a story</h2>\n<p>The way I see it, every CSS ruleset tells a story, and the story has a logical narrative arc. The order isn't arbitrary - it's the same order the browser processes properties: <strong>computed values → layout → paint → compositing</strong>.</p>\n<p>Every time I break this order, I introduce a subtle cognitive tax on the next reader, myself included. Every time I follow it, the ruleset reads like a coherent paragraph instead of a shopping list.</p>\n<p><strong>1. Custom properties.</strong> These come first because they're the inputs. They're resolved by the cascade before <em>any</em> property does anything. You want them declared before they're consumed, and grouping them at the top makes the ruleset's \"API surface\" immediately visible.</p>\n<p><strong>2. Position.</strong> Next, because it takes the element <em>out of flow</em> before you even think about sizing or coloring it. Setting <code>position: absolute</code> after <code>width</code> is technically fine, but logically backwards - you're picking a layout strategy for the element before you decide its dimensions.</p>\n<p><strong>3. Display.</strong> Right after position, because it determines the formatting context. Flex, grid, block - the element needs to know <em>how to be a box</em> before you can meaningfully set <code>gap</code>, <code>place-content</code>, or <code>align-items</code>.</p>\n<p><strong>4. Opacity &amp; visibility.</strong> These affect the box's presence without touching layout. They're the border between layout and paint.</p>\n<p><strong>5. Box-model.</strong> The geometry: <strong>inside-out</strong> - content size first, then padding, then border, then margin. <code>box-sizing</code> at the top of the section because it changes the math for everything below.</p>\n<p><strong>6. Colors &amp; background.</strong> The paint layer. Text color, background, shadows, filters. These trigger repaints but not re-layouts (mostly).</p>\n<p><strong>7. Typography.</strong> After you've painted the background, you render the text on top. <code>font</code>, <code>line-height</code>, <code>text-align</code>, <code>white-space</code>, <code>text-shadow</code>.</p>\n<p><strong>8. Transforms &amp; animations.</strong> The compositor stage. <code>transform</code>, <code>transition</code>, <code>animation</code>. These are the last thing that happens visually, but also the most performance-sensitive - having them in one block makes it easy to audit <code>will-change</code> usage.</p>\n<p><strong>9. Helpers.</strong> <code>appearance</code>, <code>cursor</code>, <code>pointer-events</code> - utility properties that don't fit anywhere else.</p>\n<p><strong>10. Pseudo-elements.</strong> <code>&amp;::before</code>, <code>&amp;::after</code> - they're part of the element's visual tree, nested inside.</p>\n<p><strong>11. Variants &amp; pseudo-selectors.</strong> State changes: <code>&amp;.error</code>, <code>&amp;[aria-hidden]</code>, <code>&amp;:hover</code>. These override everything above, so they come last in the declaration block.</p>\n<p><strong>12. Media queries &amp; children.</strong> Scoping. <code>@media</code>, <code>&amp; span</code>, <code>&amp; &gt; *</code>. These are <em>new contexts</em> that restart the hierarchy - each is a fresh ruleset that follows the same order.</p>\n<h2 id=\"the-complete-example\" tabindex=\"0\" data-toc-anchor=\"true\">The complete example</h2>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --border-radius: 5px;\n}\n\n.element {\n  /* css custom properties */\n  --var--example: 1;\n\n  /* position */\n  position: absolute;\n  inset: 0; /* top, right, bottom, left */\n  z-index: 1;\n\n  /* display */\n  display: block;\n  display: flex;\n  place-content: center;\n  place-items: center;\n  justify-self: unset;\n  gap: 1rem;\n\n  opacity: 1;\n  visibility: visible;\n\n  /* box-model */\n  box-sizing: border-box;\n  width: 10rem;\n  aspect-ratio: 16 / 9;\n  padding: 1rem;\n  border: 0.1rem solid black;\n  border-radius: 0.4rem;\n  margin: 1rem;\n  outline: 0.3rem solid black;\n  outline-offset: 0.3rem;\n\n  /* colors &amp; background */\n  color: white;\n  background-color: black;\n  background-image: url();\n  box-shadow: rgba(50, 50, 50, 1);\n  filter: drop-shadow();\n\n  /* text */\n  font-family: 'Courier New', Courier, monospace;\n  font-size: 1rem;\n  font-weight: 700;\n  line-height: normal;\n  white-space: nowrap;\n  text-align: center;\n  text-shadow: none;\n\n  /* transform &amp; animations */\n  transform: translate();\n  transition: opacity 300ms ease-in, width 500ms linear;\n  will-change: opacity, width;\n  animation: test 300ms forwards alternate-reverse;\n\n  /* helpers */\n  appearance: none;\n  cursor: pointer;\n  pointer-event: none;\n\n  /* pseudo elements */\n  &amp;::after {\n  }\n\n  /* variants &amp; pseudo selectors */\n  &amp;.error {\n    color: red;\n  }\n\n  &amp;[aria-hidden=true] {\n    display: none;\n  }\n\n  /* pseudo selectors */\n  &amp;:hover {\n  }\n\n  /* media queries */\n  @media screen and (width &gt;= 1024px) {\n    /* repeat css hierarchy here */\n  }\n\n  /* ------------ children */\n  span {\n    /* repeat css hierarchy here */\n  }\n\n  input {\n    /* repeat css hierarchy here */\n  }\n\n  &gt; * {\n    /* repeat css hierarchy here */\n  }\n}</code></pre><p>Look at that ruleset. Now look at your last project's CSS. If the two don't look alike, you know what to do.</p>\n<h2 id=\"why-the-hierarchy-holds\" tabindex=\"0\" data-toc-anchor=\"true\">Why the hierarchy holds</h2>\n<p>I'm not going to tell you this hierarchy is the One True Way™ - I've been in this game long enough to know that CSS is a language, not a religion, and anyone who tells you there's exactly one correct way to order properties is selling something (probably a linter rule they wrote). But I <em>will</em> tell you this: having <em>any</em> consistent order is infinitely better than having no order at all. The specific convention matters less than the fact that you have one and you follow it.</p>\n<p>What I <em>will</em> claim is that this specific hierarchy has survived three major migrations, six codebases, twelve team members with varying skill levels, and zero arguments. Because it's not \"my opinion\" - it's the browser's rendering pipeline encoded as property order. You can't argue with the spec. Well, you can, but you'll lose.</p>\n<p>The beautiful thing is that once you internalize this order, you stop thinking about it. You just write properties in the order they naturally fall in your head, and they happen to match the hierarchy. It becomes as automatic as indenting nested blocks or putting spaces after commas. The ruleset writes itself, and the next person who opens that file doesn't curse your name. That's the real win.</p>\n<h2 id=\"the-preprocessor-epiphany\" tabindex=\"0\" data-toc-anchor=\"true\">The preprocessor epiphany</h2>\n<p>And yeah, about the preprocessor thing: I started using this hierarchy back when I was writing Sass. When CSS custom properties landed in browsers, I realized <code>--my-var</code> slotted perfectly into the top section of the hierarchy - custom properties first, always. Nesting (<code>&amp;</code> at the bottom for variants, pseudo-elements, children) was already how I organized Sass. The hierarchy didn't change when the tooling changed. The <em>language</em> caught up to the <em>discipline</em>.</p>\n<p>That's when it clicked: the hierarchy wasn't a Sass convention. It was a <em>CSS</em> convention that happened to work in Sass. And once native CSS nesting landed in 2023, I deleted my last <code>@use 'sass'</code>, not because I hate Sass (I don't), but because the hierarchy made it irrelevant. Discipline beats tooling every time. Always has.</p>\n<h2 id=\"a-challenge-for-you\" tabindex=\"0\" data-toc-anchor=\"true\">A challenge for you</h2>\n<p>So here's my challenge: pick any ruleset in your current project. Reorder it using this hierarchy. Don't change a single value, just move the lines around. Then read it again. I bet you find a bug, or a duplicate property you didn't notice, or an override that doesn't do what you thought. The hierarchy surfaces that stuff because <em>related things are next to each other</em> instead of scattered across 80 lines.</p>\n<p>And when you find that bug, think about this: the hierarchy found it, not a linter. A linter can tell you \"duplicate property detected.\" The hierarchy tells you <em>why</em> it's a duplicate and <em>which one wins</em>.</p>\n<p>Told you. CSS is a language, not a config file.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-05-css-properties-hierarchy.png",
      "date_published": "2026-07-05T00:00:00.000Z",
      "tags": [
        "html",
        "css"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-07-04-pragmatic-service-worker.html",
      "url": "https://dout.dev/posts/2026-07-04-pragmatic-service-worker.html",
      "title": "A Pragmatic Service Worker: Cache Strategy, Offline, No Abuse (80 Lines, No Drama)",
      "summary": "The case against most service workers",
      "content_html": "<h2 id=\"the-case-against-most-service-workers\" tabindex=\"0\" data-toc-anchor=\"true\">The case against most service workers</h2>\n<p>Service workers are the feature most likely to end up on a \"cool feature I added and then forgot about\" list. The tradeoffs are real:</p>\n<ul>\n<li data-reveal=\"\">A bug in your SW can break the site for returning visitors in ways that are hard to debug and slow to fix.</li><li data-reveal=\"\">A greedy caching strategy can serve stale content to users who would prefer fresh.</li><li data-reveal=\"\">Registering an SW at all commits you to a lifecycle - updates, skip-waiting, claim - that requires careful thought.</li></ul>\n<p>Given all that, why ship one? For a blog: the offline page. For a PWA: the install experience and the background resilience. For dout.dev, both.</p>\n<p>What I refuse to do is cache everything, intercept every request, and ship an SW that tries to be a runtime framework. The dout.dev SW is about 80 lines. This post is what is in it.</p>\n<h2 id=\"the-scope\" tabindex=\"0\" data-toc-anchor=\"true\">The scope</h2>\n<p>Three jobs, in order of importance.</p>\n<ol>\n<li data-reveal=\"\"><strong>Serve an offline fallback page</strong> when the network is unreachable and the user requests a page that is not in cache.</li><li data-reveal=\"\"><strong>Cache the critical shell</strong> - the home page, the main CSS, the primary script bundle - so the next visit is instant even on a cold network.</li><li data-reveal=\"\"><strong>Cache visited posts</strong> on a stale-while-revalidate basis, so re-reading a post is instant and returning to it offline works.</li></ol>\n<p>Everything else - images, feeds, analytics beacons, third-party assets - is not intercepted.</p>\n<h2 id=\"the-precache-list\" tabindex=\"0\" data-toc-anchor=\"true\">The precache list</h2>\n<p>At install time, the SW precaches exactly the files needed to render the offline experience. That list is generated by the build and inlined into the SW file, so there is no manifest to drift.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const PRECACHE = 'dout-precache-v1';\nconst RUNTIME = 'dout-runtime-v1';\n\nconst PRECACHE_URLS = ['/', '/offline.html', '/styles/index.css', '/scripts/main.js', '/assets/favicon.svg'];\n\nself.addEventListener('install', (event) =&gt; {\n  event.waitUntil(caches.open(PRECACHE).then((cache) =&gt; cache.addAll(PRECACHE_URLS)));\n});</code></pre><p>The precache name includes a version (<code>-v1</code>). Bumping that version on a release invalidates the precache cleanly.</p>\n<h2 id=\"the-activate-cleanup\" tabindex=\"0\" data-toc-anchor=\"true\">The activate cleanup</h2>\n<p>On activation, old caches get deleted. Without this, users accumulate dead caches forever.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>self.addEventListener('activate', (event) =&gt; {\n  const valid = new Set([PRECACHE, RUNTIME]);\n  event.waitUntil(\n    caches.keys().then((names) =&gt; Promise.all(names.filter((n) =&gt; !valid.has(n)).map((n) =&gt; caches.delete(n))))\n  );\n  self.clients.claim();\n});</code></pre><p><code>self.clients.claim()</code> takes control of existing tabs on the first activation, so the new SW is in charge immediately instead of after a full reload. That is a judgment call - some projects prefer to wait for a reload to avoid mid-session inconsistencies. For a read-only blog, claiming is safe.</p>\n<h2 id=\"the-fetch-handler-in-three-cases\" tabindex=\"0\" data-toc-anchor=\"true\">The fetch handler, in three cases</h2>\n<p>The fetch handler has three branches, and each is small.</p>\n<h3 id=\"1-html-navigation-requests\" tabindex=\"0\" data-toc-anchor=\"true\">1. HTML navigation requests</h3>\n<p>For navigation (a page load), try the network first. If the network wins, cache the response for later. If the network fails, serve the cached version, and if that is missing, the offline page.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>self.addEventListener('fetch', (event) =&gt; {\n  const req = event.request;\n  if (req.mode === 'navigate') {\n    event.respondWith(\n      fetch(req)\n        .then((res) =&gt; {\n          const copy = res.clone();\n          caches.open(RUNTIME).then((cache) =&gt; cache.put(req, copy));\n          return res;\n        })\n        .catch(() =&gt; caches.match(req).then((cached) =&gt; cached || caches.match('/offline.html')))\n    );\n    return;\n  }\n  // ...\n});</code></pre><p>Network-first for HTML means readers always get the freshest post when they are online. The cache is a fallback, not a source of truth.</p>\n<h3 id=\"2-same-origin-static-assets\" tabindex=\"0\" data-toc-anchor=\"true\">2. Same-origin static assets</h3>\n<p>For CSS, JS, and fonts on the same origin, cache-first with a background revalidate. This is the classic stale-while-revalidate pattern:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>if (req.destination === 'style' || req.destination === 'script' || req.destination === 'font') {\n  event.respondWith(\n    caches.match(req).then((cached) =&gt; {\n      const networkFetch = fetch(req).then((res) =&gt; {\n        const copy = res.clone();\n        caches.open(RUNTIME).then((cache) =&gt; cache.put(req, copy));\n        return res;\n      });\n      return cached || networkFetch;\n    })\n  );\n  return;\n}</code></pre><p>Returning cached content immediately keeps the page fast; the background fetch updates the cache for next time. The trade-off is that the first load after a deploy serves the old bundle, and the fresh one is picked up on the next navigation. For a blog, that is acceptable.</p>\n<h3 id=\"3-everything-else\" tabindex=\"0\" data-toc-anchor=\"true\">3. Everything else</h3>\n<p>Images, feeds, third-party URLs, analytics beacons - pass through to the network without touching the cache. The SW explicitly does not intercept.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>// fall through to the network</code></pre><p>Not caching something is a decision too. It avoids the trap of caching things you never meant to cache and then not being able to invalidate them.</p>\n<h2 id=\"the-offline-page\" tabindex=\"0\" data-toc-anchor=\"true\">The offline page</h2>\n<p><code>offline.html</code> is a static page with the branding and a short message. It links to the home (which might be cached) and includes a small retry button that reloads the page.</p>\n<p>The only trick: the offline page must not reference uncached resources. If the offline CSS is not in the precache, the page still renders but unstyled. The precache list above includes the main CSS, so this works.</p>\n<h2 id=\"update-strategy\" tabindex=\"0\" data-toc-anchor=\"true\">Update strategy</h2>\n<p>The SW updates itself when the browser fetches <code>/sw.js</code> and notices it differs byte-for-byte from the registered one. Because the precache list contains a version string, a deploy that bumps the precache version triggers an update.</p>\n<p>For a blog, that is enough. I do not ship an explicit \"update available\" banner. The user gets the new SW on the next navigation after a deploy, and the cleanup handler deletes the old cache.</p>\n<h2 id=\"what-i-did-not-add\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not add</h2>\n<ul>\n<li data-reveal=\"\"><strong>Background sync.</strong> The blog has nothing to sync. Readers do not post content.</li><li data-reveal=\"\"><strong>Push notifications.</strong> Reader-initiated subscriptions belong to the RSS layer.</li><li data-reveal=\"\"><strong>Periodic background sync.</strong> Same reason.</li><li data-reveal=\"\"><strong>Navigation preload.</strong> A legitimate optimization, but it adds complexity I did not need for the current page load times.</li></ul>\n<p>Each of these is a feature I could add later without restructuring the SW. Keeping the current one small is the point.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A service worker on a blog is worth the 80 lines if you ship the three jobs: offline fallback, shell precache, and a conservative runtime cache for repeat visits. Resist the urge to intercept every request. The bugs you avoid by caching less are worth more than the performance gains from caching more.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Service Worker API - MDN</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/service-worker-lifecycle?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">The Service Worker Lifecycle - web.dev</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/offline-cookbook?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Offline cookbook - Jake Archibald</a></li><li data-reveal=\"\"><a href=\"https://developer.chrome.com/docs/workbox?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Workbox</a> - if you want pre-built recipes</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-07-04-pragmatic-service-worker.png",
      "date_published": "2026-07-04T00:00:00.000Z",
      "tags": [
        "performance",
        "vanilla-js",
        "architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-30-honest-lazy-loading.html",
      "url": "https://dout.dev/posts/2026-06-30-honest-lazy-loading.html",
      "title": "Honest Lazy Loading: IntersectionObserver vs Native loading=lazy (The Real Difference)",
      "summary": "The two tools, in one paragraph",
      "content_html": "<h2 id=\"the-two-tools-in-one-paragraph\" tabindex=\"0\" data-toc-anchor=\"true\">The two tools, in one paragraph</h2>\n<p>Native <code>loading=\"lazy\"</code> on <code>&lt;img&gt;</code> and <code>&lt;iframe&gt;</code> is a browser-managed hint. The browser decides when to load based on its own heuristics, and it usually does a good job. IntersectionObserver is a programmable primitive that tells your code exactly when an element enters a viewport band. Both are useful. They are not the same thing, and treating them as interchangeable is how you end up with images that load too late or scripts that fire too early.</p>\n<p>This post is the mental model I use to pick between them.</p>\n<h2 id=\"the-happy-path-native-lazy-on-images-below-the-fold\" tabindex=\"0\" data-toc-anchor=\"true\">The happy path: native lazy on images below the fold</h2>\n<p>For most images on a blog, native lazy loading is correct and sufficient.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img src=\"keyboard.jpg\" alt=\"A keyboard on a wooden desk\" width=\"1920\" height=\"1280\" loading=\"lazy\" decoding=\"async\" /&gt;</code></pre><p>What this gets you, for free:</p>\n<ul>\n<li data-reveal=\"\">The image does not download until the browser predicts it is needed.</li><li data-reveal=\"\">No JavaScript required.</li><li data-reveal=\"\">The <code>decoding=\"async\"</code> hint tells the browser it can decode off the main thread.</li><li data-reveal=\"\"><code>width</code> and <code>height</code> reserve the aspect-ratio box so there is no layout shift when the image eventually arrives.</li></ul>\n<p>The browser's heuristic is not perfect, but it is tuned for the 80% case of scrolling pages. If your images are content images on a standard reading flow, <code>loading=\"lazy\"</code> does the job.</p>\n<h2 id=\"where-native-lazy-is-wrong\" tabindex=\"0\" data-toc-anchor=\"true\">Where native lazy is wrong</h2>\n<p>Three situations where you should not use <code>loading=\"lazy\"</code>, or where you have to combine it with IntersectionObserver.</p>\n<h3 id=\"1-lcp-candidates-above-the-fold\" tabindex=\"0\" data-toc-anchor=\"true\">1. LCP candidates above the fold</h3>\n<p>The image that is going to be your Largest Contentful Paint should load eagerly. Setting <code>loading=\"lazy\"</code> on an LCP image delays the one number that most affects your page quality metric.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img src=\"hero.jpg\" alt=\"Hero image\" width=\"1200\" height=\"800\" loading=\"eager\" fetchpriority=\"high\" /&gt;</code></pre><p><code>fetchpriority=\"high\"</code> tells the browser this resource should be prioritized over others. Use it sparingly - if everything is \"high\", nothing is. One LCP candidate per page.</p>\n<h3 id=\"2-source-inside-picture\" tabindex=\"0\" data-toc-anchor=\"true\">2. <code>&lt;source&gt;</code> inside <code>&lt;picture&gt;</code></h3>\n<p><code>loading=\"lazy\"</code> on an <code>&lt;img&gt;</code> inside a <code>&lt;picture&gt;</code> applies to the image as a whole. But the <code>&lt;source&gt;</code> elements inside the picture have already been evaluated by the time the browser decides whether to defer the image. The browser will still download a WebP variant even if the fallback <code>&lt;img&gt;</code> is lazy.</p>\n<p>For large below-the-fold <code>&lt;picture&gt;</code> blocks, the fix is to store the <code>srcset</code> in <code>data-srcset</code> and swap it with IntersectionObserver when the element is near the viewport.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;picture&gt;\n  &lt;source\n    type=\"image/webp\"\n    data-srcset=\"/img/hero-320.webp 320w, /img/hero-640.webp 640w\"\n    sizes=\"(max-width: 640px) 100vw, 640px\"\n  /&gt;\n  &lt;img src=\"/img/hero.jpg\" alt=\"...\" width=\"1920\" height=\"1280\" loading=\"lazy\" /&gt;\n&lt;/picture&gt;</code></pre><pre is=\"pix-highlighter\" data-lang=\"js\"><code>const io = new IntersectionObserver(\n  (entries) =&gt; {\n    for (const entry of entries) {\n      if (!entry.isIntersecting) continue;\n      const source = entry.target;\n      source.srcset = source.dataset.srcset;\n      source.removeAttribute('data-srcset');\n      io.unobserve(source);\n    }\n  },\n  { rootMargin: '200px' }\n);\n\ndocument.querySelectorAll('source[data-srcset]').forEach((s) =&gt; io.observe(s));</code></pre><p>This is the only reliable way I have found to avoid eager downloads of large WebP variants for below-the-fold pictures.</p>\n<h3 id=\"3-iframes-from-third-parties\" tabindex=\"0\" data-toc-anchor=\"true\">3. Iframes from third parties</h3>\n<p>Giscus, CodePen embeds, video embeds: these are expensive third-party resources that you do not want to load on every page view. <code>loading=\"lazy\"</code> on the iframe helps, but you often want stricter control - load only when the user scrolls close, or only when a \"Show comments\" button is pressed.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;div class=\"comments-shell\" data-giscus-src=\"https://giscus.app/client.js\" data-giscus-attrs='{ \"data-repo\": \"...\" }'&gt;\n  &lt;button type=\"button\" class=\"load-comments\"&gt;Load comments&lt;/button&gt;\n&lt;/div&gt;</code></pre><pre is=\"pix-highlighter\" data-lang=\"js\"><code>document.querySelector('.load-comments')?.addEventListener('click', (e) =&gt; {\n  const shell = e.target.closest('.comments-shell');\n  const attrs = JSON.parse(shell.dataset.giscusAttrs);\n  const script = document.createElement('script');\n  script.src = shell.dataset.giscusSrc;\n  for (const [k, v] of Object.entries(attrs)) script.setAttribute(k, v);\n  script.crossOrigin = 'anonymous';\n  script.async = true;\n  shell.appendChild(script);\n  e.target.remove();\n});</code></pre><p>The cost of opting into \"click to load\" for comments is one button and a 20-line handler. The benefit is a significantly smaller critical path for readers who do not engage with comments.</p>\n<p>On dout.dev I ship the Giscus embed lazily via <code>data-loading=\"lazy\"</code> and do not hide it behind a button, because comments are part of the editorial experience. On a page with heavier embeds, the button pattern is the right default.</p>\n<h2 id=\"the-mental-model\" tabindex=\"0\" data-toc-anchor=\"true\">The mental model</h2>\n<ul>\n<li data-reveal=\"\">Is the element above the fold and likely the LCP? → <code>loading=\"eager\"</code> + <code>fetchpriority=\"high\"</code>.</li><li data-reveal=\"\">Is it an image or plain iframe below the fold? → <code>loading=\"lazy\"</code>.</li><li data-reveal=\"\">Is it a <code>&lt;source&gt;</code> inside a <code>&lt;picture&gt;</code> below the fold? → IntersectionObserver with <code>data-srcset</code> swap.</li><li data-reveal=\"\">Is it a heavy third-party embed? → IntersectionObserver with a bigger root margin, or a click-to-load button.</li></ul>\n<p>Everything else is a variation.</p>\n<h2 id=\"what-i-do-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not do</h2>\n<p>I do not reinvent native lazy for plain images. I do not ship a \"lazyload.js\" dependency. I do not observe scroll events. IntersectionObserver is already in every target browser and has been for years.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Native lazy is a good default. Use it first. Reach for IntersectionObserver when the browser's heuristic is not under your control (nested picture sources, third-party iframes, expensive runtime costs). The two tools complement each other; they do not compete.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/loading?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>loading</code> attribute - MDN</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/fetchpriority?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>fetchpriority</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">IntersectionObserver - MDN</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/browser-level-image-lazy-loading?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Browser-level image lazy loading - web.dev</a></li><li data-reveal=\"\"><a href=\"https://web.dev/articles/iframe-lazy-loading?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Lazy-loading iframes - web.dev</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-30-honest-lazy-loading.png",
      "date_published": "2026-06-30T00:00:00.000Z",
      "tags": [
        "performance",
        "frontend",
        "vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-27-accessible-scrollspy-outline.html",
      "url": "https://dout.dev/posts/2026-06-27-accessible-scrollspy-outline.html",
      "title": "Accessible Scrollspy and Outline Without contenteditable (Yes, It Can Be Done)",
      "summary": "The feature and its trap",
      "content_html": "<h2 id=\"the-feature-and-its-trap\" tabindex=\"0\" data-toc-anchor=\"true\">The feature and its trap</h2>\n<p>A long post benefits from a sidebar outline that highlights the current section as the user scrolls. On a blog with 1500-word articles, it is the difference between a linear reading experience and a navigable document.</p>\n<p>Most scrollspy implementations have three accessibility problems.</p>\n<ol>\n<li data-reveal=\"\">They rely on the browser's scroll event, which fires too often and updates the active state with a jitter that confuses screen readers.</li><li data-reveal=\"\">They make the headings focusable in ways that break keyboard expectations - <code>tabindex=\"0\"</code> on every <code>h2</code> is a trap, not a feature.</li><li data-reveal=\"\">They announce the current section via <code>aria-current</code> that changes several times per second during scroll, which turns the outline into a screaming live region.</li></ol>\n<p>This post is the design I landed on after tripping over all three.</p>\n<h2 id=\"the-dom-shape\" tabindex=\"0\" data-toc-anchor=\"true\">The DOM shape</h2>\n<p>The outline is a normal <code>&lt;nav&gt;</code> with a list of anchor links to heading IDs. That is the document you would get from a static site with no JavaScript. Everything else is enhancement.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;aside class=\"post-outline\"&gt;\n  &lt;nav aria-label=\"Article outline\"&gt;\n    &lt;ol&gt;\n      &lt;li&gt;\n        &lt;a href=\"#the-feature-and-its-trap\"&gt;The feature and its trap&lt;/a&gt;\n      &lt;/li&gt;\n      &lt;li&gt;\n        &lt;a href=\"#the-dom-shape\"&gt;The DOM shape&lt;/a&gt;\n      &lt;/li&gt;\n      &lt;li&gt;\n        &lt;a href=\"#observing-headings-not-scroll\"&gt;Observing headings, not scroll&lt;/a&gt;\n      &lt;/li&gt;\n    &lt;/ol&gt;\n  &lt;/nav&gt;\n&lt;/aside&gt;</code></pre><p>Without JS: you get a jump-link navigation. With JS: the active link gets <code>aria-current=\"location\"</code> based on what is currently visible.</p>\n<h2 id=\"headings-have-ids-and-they-are-focusable-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">Headings have IDs, and they are focusable on purpose</h2>\n<p>The post generator emits IDs on every heading (<code>## The DOM shape</code> → <code>id=\"the-dom-shape\"</code>). It also adds <code>tabindex=\"-1\"</code> to headings, so they can receive programmatic focus when a user activates an outline link. Without that, focus remains on the link that was activated, and the next tab stop is inside the link list instead of the section body.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;h2 id=\"the-dom-shape\" tabindex=\"-1\"&gt;The DOM shape&lt;/h2&gt;</code></pre><p><code>tabindex=\"-1\"</code> makes the heading programmatically focusable without adding it to the tab order. That is the shape you want for anchor targets. <code>tabindex=\"0\"</code> would make every heading a tab stop and is wrong.</p>\n<h2 id=\"observing-headings-not-scroll\" tabindex=\"0\" data-toc-anchor=\"true\">Observing headings, not scroll</h2>\n<p>Listening to <code>scroll</code> and computing which heading is \"current\" is a trap. It fires constantly, it does not know about the viewport's relevance cone, and it forces you to recompute heading positions on resize.</p>\n<p>The right primitive is IntersectionObserver. Point it at the headings with a top-biased root margin, and let it tell you when headings enter or leave the relevance zone.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const headings = document.querySelectorAll('article h2[id], article h3[id]');\nconst outline = document.querySelector('.post-outline');\n\nlet currentId = null;\n\nconst io = new IntersectionObserver(\n  (entries) =&gt; {\n    for (const entry of entries) {\n      if (entry.isIntersecting) {\n        currentId = entry.target.id;\n      }\n    }\n    if (currentId) updateOutline(currentId);\n  },\n  {\n    rootMargin: '-20% 0px -70% 0px',\n    threshold: 0,\n  }\n);\n\nfor (const h of headings) io.observe(h);</code></pre><p>The <code>rootMargin</code> shrinks the \"active\" band to the upper part of the viewport. A heading is \"current\" when it enters that band, which matches the reader's expectation.</p>\n<h2 id=\"updating-the-outline-without-thrashing-aria\" tabindex=\"0\" data-toc-anchor=\"true\">Updating the outline without thrashing ARIA</h2>\n<p>The update function removes <code>aria-current</code> from all outline links and adds it only to the active one. That is a tiny DOM change, not a re-render.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function updateOutline(id) {\n  const active = outline.querySelector('[aria-current]');\n  if (active) active.removeAttribute('aria-current');\n\n  const next = outline.querySelector(`a[href=\"#${CSS.escape(id)}\"]`);\n  if (next) next.setAttribute('aria-current', 'location');\n}</code></pre><p><code>aria-current=\"location\"</code> is the correct value for \"this link points at the user's current location in the document.\" <code>aria-current=\"page\"</code> is wrong here; that one is for pagination or site navigation.</p>\n<h2 id=\"throttling-is-not-needed\" tabindex=\"0\" data-toc-anchor=\"true\">Throttling is not needed</h2>\n<p>IntersectionObserver is already asynchronous and batched. Callbacks fire at animation-frame cadence at most, and only when the observed elements actually cross a threshold. No <code>requestAnimationFrame</code> wrapper, no <code>throttle</code>, no debouncer. Writing one of those on top of IntersectionObserver is a code smell.</p>\n<h2 id=\"smooth-scroll-and-focus-after-link-click\" tabindex=\"0\" data-toc-anchor=\"true\">Smooth scroll and focus after link click</h2>\n<p>When the user clicks an outline link, the default behavior jumps to the anchor. On dout.dev the behavior is enhanced: smooth-scroll to the heading, then move focus to the heading so that subsequent tab keys land inside the section.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>outline.addEventListener('click', (e) =&gt; {\n  const link = e.target.closest('a[href^=\"#\"]');\n  if (!link) return;\n  e.preventDefault();\n\n  const id = decodeURIComponent(link.hash.slice(1));\n  const target = document.getElementById(id);\n  if (!target) return;\n\n  target.scrollIntoView({ behavior: 'smooth', block: 'start' });\n  target.focus({ preventScroll: true });\n\n  history.pushState(null, '', `#${id}`);\n});</code></pre><p>Two subtleties.</p>\n<p><strong><code>preventScroll: true</code> on focus.</strong> Without it, <code>focus()</code> scrolls the heading to the top of the viewport, which fights the smooth-scroll animation.</p>\n<p><strong><code>history.pushState</code> instead of assigning <code>location.hash</code>.</strong> Setting the hash re-triggers the native jump and cancels the smooth scroll. Pushing the URL manually gives the user a shareable link without breaking the animation.</p>\n<h2 id=\"reduced-motion\" tabindex=\"0\" data-toc-anchor=\"true\">Reduced motion</h2>\n<p>Anyone with <code>prefers-reduced-motion: reduce</code> gets an instant scroll instead of smooth.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;\ntarget.scrollIntoView({\n  behavior: reduceMotion ? 'auto' : 'smooth',\n  block: 'start',\n});</code></pre><p>The cost is one line. The benefit is that users with vestibular sensitivity do not get attacked by your animations.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A good scrollspy is three primitives the platform already gives you: IDs on headings, <code>tabindex=\"-1\"</code> on the targets, and IntersectionObserver for the activation logic. Anything more than that is a leak.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">IntersectionObserver - MDN</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/TR/wai-aria-1.2/?from=dout.dev#aria-current\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>aria-current</code> - W3C ARIA</a></li><li data-reveal=\"\"><a href=\"https://html.spec.whatwg.org/multipage/interaction.html?from=dout.dev#attr-tabindex\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>tabindex</code> - HTML Living Standard</a></li><li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>prefers-reduced-motion</code> - MDN</a></li><li data-reveal=\"\"><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.2 2.4.11 Focus Not Obscured (Minimum)</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-27-accessible-scrollspy-outline.png",
      "date_published": "2026-06-27T00:00:00.000Z",
      "tags": [
        "accessibility",
        "vanilla-js",
        "frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-23-custom-element-syntax-highlight.html",
      "url": "https://dout.dev/posts/2026-06-23-custom-element-syntax-highlight.html",
      "title": "A Custom Element for Syntax Highlight: Why I Wrote `pix-highlighter`",
      "summary": "The stack I did not want",
      "content_html": "<h2 id=\"the-stack-i-did-not-want\" tabindex=\"0\" data-toc-anchor=\"true\">The stack I did not want</h2>\n<p>Syntax highlighting on a blog is one of those problems with three plausible solutions, all of which bring a tax.</p>\n<ul>\n<li data-reveal=\"\"><strong>Prism or highlight.js at runtime.</strong> Small API, reasonable language support, but it ships JavaScript to every reader even for cold visits that never view code.</li><li data-reveal=\"\"><strong>Shiki at build time.</strong> Produces beautiful, VS Code-parity output, but pulls a full TextMate grammar engine into the build. The dependency graph is non-trivial and the output HTML is dense.</li><li data-reveal=\"\"><strong>Pygments or Rouge via a Ruby dependency.</strong> Excellent output, but I did not want a second-language toolchain in the pipeline.</li></ul>\n<p>For dout.dev I wanted something smaller than all three, with full control over the CSS tokens. The result is <code>pix-highlighter</code>, a custom element that takes the markdown fenced-code output, tokenizes on the client with a small lexer, and emits <code>&lt;span&gt;</code> tags keyed to design system tokens.</p>\n<p>That choice has trade-offs. This post explains them honestly.</p>\n<h2 id=\"what-the-renderer-emits\" tabindex=\"0\" data-toc-anchor=\"true\">What the renderer emits</h2>\n<p>The markdown renderer does not highlight. It emits structural markup:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;pre is=\"pix-highlighter\" lang=\"js\"&gt;\n  &lt;code&gt;function hello() { return 42; }&lt;/code&gt;\n&lt;/pre&gt;</code></pre><p><code>&lt;pre is=\"pix-highlighter\" lang=\"js\"&gt;</code> is a customized built-in element. It upgrades <code>&lt;pre&gt;</code> with new behavior while keeping the semantic element intact. Screen readers and copy-paste behave correctly; the upgrade is purely visual.</p>\n<h2 id=\"the-custom-element\" tabindex=\"0\" data-toc-anchor=\"true\">The custom element</h2>\n<p>The element is under 300 lines. It knows how to:</p>\n<ol>\n<li data-reveal=\"\">Read <code>lang</code> and pick the lexer.</li><li data-reveal=\"\">Tokenize the text content into <code>{ type, value }</code> tuples.</li><li data-reveal=\"\">Render a sequence of <code>&lt;span class=\"tok-&lt;type&gt;\"&gt;</code> wrapping the tokens.</li><li data-reveal=\"\">Expose a <code>copy</code> button that puts the raw source on the clipboard.</li></ol>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>class PixHighlighter extends HTMLPreElement {\n  connectedCallback() {\n    const code = this.querySelector('code');\n    if (!code || this.dataset.highlighted) return;\n\n    const lang = this.getAttribute('lang');\n    const lexer = LEXERS[lang];\n    if (!lexer) return;\n\n    const tokens = lexer(code.textContent);\n    code.innerHTML = tokens.map((t) =&gt; `&lt;span class=\"tok-${t.type}\"&gt;${escapeHtml(t.value)}&lt;/span&gt;`).join('');\n\n    this.dataset.highlighted = 'true';\n    this.appendCopyButton(code.textContent);\n  }\n}\n\ncustomElements.define('pix-highlighter', PixHighlighter, { extends: 'pre' });</code></pre><p>The element only runs where <code>&lt;pre is=\"pix-highlighter\"&gt;</code> exists in the DOM. The bulk of the site - every page without a code block - pays nothing for it.</p>\n<h2 id=\"the-lexers-are-small-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">The lexers are small on purpose</h2>\n<p>Each lexer is a single function that walks the string once and emits tokens. The language coverage is intentionally narrow: JS, TS, CSS, HTML, JSON, Bash, Python, Go, Rust, C, C++, PHP, C#, YAML, Markdown.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function lexJs(source) {\n  const tokens = [];\n  let i = 0;\n  while (i &lt; source.length) {\n    const rest = source.slice(i);\n    let m;\n    if ((m = rest.match(/^\\/\\/[^\\n]*/))) {\n      tokens.push({ type: 'comment', value: m[0] });\n    } else if ((m = rest.match(/^\"(?:[^\"\\\\]|\\\\.)*\"/))) {\n      tokens.push({ type: 'string', value: m[0] });\n    } else if ((m = rest.match(/^\\b(function|return|const|let|var|if|else|for)\\b/))) {\n      tokens.push({ type: 'keyword', value: m[0] });\n    } else if ((m = rest.match(/^\\d+(?:\\.\\d+)?/))) {\n      tokens.push({ type: 'number', value: m[0] });\n    } else if ((m = rest.match(/^\\s+/))) {\n      tokens.push({ type: 'ws', value: m[0] });\n    } else {\n      tokens.push({ type: 'text', value: source[i] });\n      i += 1;\n      continue;\n    }\n    i += m[0].length;\n  }\n  return tokens;\n}</code></pre><p>This is not correct in the \"TextMate-grade\" sense. It does not understand JSX, template literal interpolation, or JSDoc. It is correct enough for blog code samples, which are short, self-contained, and visually parseable.</p>\n<p>If I wanted the last 5% of fidelity, I would use Shiki. I did not.</p>\n<h2 id=\"the-css-is-design-system-tokens-not-theme-files\" tabindex=\"0\" data-toc-anchor=\"true\">The CSS is design system tokens, not theme files</h2>\n<p>Because the element emits <code>&lt;span class=\"tok-string\"&gt;</code> and similar, the CSS lives in the design system. Colors reference semantic tokens, which means the highlighter follows the theme switcher automatically.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>pre[is='pix-highlighter'] {\n  background: var(--color-code-bg);\n  color: var(--color-code-fg);\n  padding: var(--space-4);\n  border-radius: var(--radius-2);\n  font: var(--font-mono);\n}\n\n.tok-comment {\n  color: var(--color-code-comment);\n  font-style: italic;\n}\n.tok-string {\n  color: var(--color-code-string);\n}\n.tok-keyword {\n  color: var(--color-code-keyword);\n  font-weight: 600;\n}\n.tok-number {\n  color: var(--color-code-number);\n}</code></pre><p>No separate \"light theme\" and \"dark theme\" stylesheets. One set of rules, driven by semantic tokens, which flip based on <code>data-color-scheme</code>.</p>\n<h2 id=\"accessibility\" tabindex=\"0\" data-toc-anchor=\"true\">Accessibility</h2>\n<p>The copy-to-clipboard button has a visible label and an accessible name. The <code>&lt;pre&gt;</code> has a semantic code region, the <code>&lt;code&gt;</code> inside keeps the text content intact, and the token spans are decorative - aria-hidden would be wrong because they do contain the text the screen reader should read; the tokens are styling, not semantics.</p>\n<p>On a keyboard-only pass, the copy button receives focus with a visible ring, press fires the copy, and <code>aria-live=\"polite\"</code> on a sibling span announces \"Copied.\"</p>\n<h2 id=\"when-i-would-not-do-this\" tabindex=\"0\" data-toc-anchor=\"true\">When I would not do this</h2>\n<p>If the blog needed twenty languages with accurate semantic highlighting (JSX, template literals, complex macro systems), the cost of maintaining a handwritten lexer family would exceed the cost of adopting Shiki at build time. The trade-off is genuinely a spectrum.</p>\n<p>The cutoff I used: fewer than twenty languages, short code samples, theme integration matters, bundle size matters, fidelity at the 95% level is acceptable. Write your own. Otherwise, use Shiki.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Custom elements are underrated. A ~300-line <code>pix-highlighter</code> replaces a dependency I would have carried forever, integrates with the design system instead of a theme file, and only runs where it is needed. That pattern - small, scoped, declarative - fits the rest of dout.dev.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Web Components: Custom Elements - MDN</a></li><li data-reveal=\"\"><a href=\"https://html.spec.whatwg.org/multipage/custom-elements.html?from=dout.dev#customized-built-in-elements\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Customized built-in elements - HTML Living Standard</a></li><li data-reveal=\"\"><a href=\"https://shiki.style/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Shiki</a> - if you need TextMate-grade output</li><li data-reveal=\"\"><a href=\"https://prismjs.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Prism</a> - if you need runtime highlighting with minimal setup</li><li data-reveal=\"\"><a href=\"https://highlightjs.org/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Highlight.js</a> - the other runtime option</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-23-custom-element-syntax-highlight.png",
      "date_published": "2026-06-23T00:00:00.000Z",
      "tags": [
        "vanilla-js",
        "architecture",
        "frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-20-pi-dev-deepseek-v4-flash-daily-loop.html",
      "url": "https://dout.dev/posts/2026-06-20-pi-dev-deepseek-v4-flash-daily-loop.html",
      "title": "Pi and DeepSeek V4 Flash: The Daily Coding Loop That Costs Almost Nothing",
      "summary": "The headline numbers",
      "content_html": "<h2 id=\"the-headline-numbers\" tabindex=\"0\" data-toc-anchor=\"true\">The headline numbers</h2>\n<p>I will get the math out of the way first, because the cost is the part that surprises people.</p>\n<ul>\n<li data-reveal=\"\"><strong>Pi</strong> is an open-source terminal coding agent. MIT-licensed, written in TypeScript, four core tools (<code>read</code>, <code>write</code>, <code>edit</code>, <code>bash</code>) plus three opt-in read-only ones (<code>grep</code>, <code>find</code>, <code>ls</code>). No plan mode, no sub-agents, no permission popups, no IDE lock-in.</li><li data-reveal=\"\"><strong>DeepSeek V4 Flash</strong> is a 284B-parameter Mixture-of-Experts model with only 13B active per token, a 1M-token context window, and pricing that lands at <strong>$0.14 per million input tokens</strong> (cache miss), <strong>$0.0028 per million input tokens</strong> (cache hit), and <strong>$0.28 per million output tokens</strong> on the official API. Open weights, MIT license.</li><li data-reveal=\"\">A typical agent-loop session of mine reads, edits, and writes across a few hundred kilobytes of context, runs a few tool calls, and produces a few thousand tokens of reasoning. The bill is <strong>fractions of a cent</strong>.</li><li data-reveal=\"\">A heavy day, the kind where I ship two milestones and refactor a subsystem end-to-end, lands somewhere between <strong>$0.30 and $0.90</strong>.</li></ul>\n<p>That is the actual economic story of this combination. It is not a benchmark artifact. It is the loop I work in every day. This post is the full setup, the daily workflow, the cost math, the things it does not do well, and why I now treat it as the default.</p>\n<h2 id=\"what-pi-dev-is-and-what-it-deliberately-is-not\" tabindex=\"0\" data-toc-anchor=\"true\">What pi.dev is, and what it deliberately is not</h2>\n<p>Pi is the work of Mario Zechner, now maintained under the <code>earendil-works</code> GitHub organization. It is a terminal-native coding harness: you run it, it reads your repo, it proposes edits, it runs shell commands, and you review every change before it lands on disk. The repository sits at around 66.5K stars, and it has a very strong opinion about what a coding agent should be.</p>\n<p>The opinion is <strong>minimalism without being stupid</strong>.</p>\n<p>Pi ships with exactly four core tools: <code>read</code>, <code>write</code>, <code>edit</code>, and <code>bash</code>. Three more, <code>grep</code>, <code>find</code>, and <code>ls</code>, are available as opt-in read-only tools. That is the entire built-in surface. Everything else is a TypeScript extension, a skill, a prompt template, or a theme. There is no plan mode because you can write a prompt template that asks the model to plan first. There is no sub-agent system because you can write a skill that delegates. There is no permission system because you are expected to read the diff before the model writes to disk.</p>\n<p>That last point is the one most people miss. Pi does not remove safety. Pi removes <strong>friction</strong>. You still review every change. You still see the exact command before it runs. You still decide when the loop is done. What you give up is a layer of confirmation dialogs that, in my experience, do not actually catch the kind of mistakes that matter and mostly just slow down the parts of the work that are already correct.</p>\n<p>And the entire thing is <strong>open source at its core</strong>. The repository at <a href=\"https://github.com/earendil-works/pi?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">github.com/earendil-works/pi</a> is MIT-licensed, and everything - the core loop, the tool harness, the MCP bridge, the provider abstraction - is readable, forkable, and hackable. You can strip it down, patch it, or build your own distribution. There is no proprietary layer, no closed-source enterprise edition, no telemetry-gated feature. The CLI, the TUI, the extension system, and the skill loader are all there in plain TypeScript.</p>\n<p>The consequence is that the extension API is unusually clean. Pi exposes a small, well-documented set of primitives - tools, MCP servers, skills, prompt templates, and themes - and you wire them together with a <code>package.json</code> and a single <code>main</code> file. The package manager (<code>pi packages</code>) discovers, installs, and updates extensions from <a href=\"https://pi.dev/packages/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">pi.dev/packages</a>, where anyone can publish.</p>\n<p>I maintain a small extension myself: <a href=\"https://pi.dev/packages/@pixu1980/pi-path-picker?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>@pixu1980/pi-path-picker</code></a>, a tool that autocompletes file paths inside the agent prompt. The source lives at <a href=\"https://github.com/pixu1980/pi-coding-agent-extensions?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">github.com/pixu1980/pi-coding-agent-extensions</a>, and the entire implementation - registering a custom tool, hooking into the prompt lifecycle, handling tab-completion in the TUI - fits in a handful of files. It is a good example of how little ceremony is involved: you write a TypeScript class, export it, publish it, and it works. No build step beyond TypeScript, no configuration wizard, no permission manifest.</p>\n<p>The other thing Pi gets right is the provider model. It supports 20+ providers out of the box, including Anthropic, OpenAI, Google, xAI, Mistral, Groq, OpenRouter, and DeepSeek. DeepSeek is a first-class native provider because it speaks the OpenAI-compatible API, and you can switch models mid-session. The configuration lives in <code>~/.pi/agent/models.json</code> and you can register as many models as you want.</p>\n<p>I have seven models registered at any given time. I switch between them depending on the task. The default for almost everything I do is DeepSeek V4 Flash.</p>\n<h2 id=\"what-deepseek-v4-flash-actually-is\" tabindex=\"0\" data-toc-anchor=\"true\">What DeepSeek V4 Flash actually is</h2>\n<p>DeepSeek V4 launched in preview on <strong>April 24, 2026</strong>, in two variants. V4-Pro is the flagship: 1.6T total parameters, 49B active, around $1.74 per million input tokens and $3.48 per million output tokens. V4-Flash is the cost-optimized tier: 284B total, <strong>13B active per token</strong>, 1M context window, 2,500 concurrent requests, and the pricing I quoted above.</p>\n<p>The interesting design choice is the MoE split. 284B sounds enormous, but only 13B parameters run on any given token, which is what gives Flash its cost and latency profile. The model is not a \"small model that tries to look big\" - it is a properly sparse MoE that pays a small compute bill per token while still benefiting from a much larger knowledge base when routing. DeepSeek also uses a sparse attention scheme (CSA / HCA) to keep the 1M-context long-tail cheap, which is exactly what an agent loop needs.</p>\n<p>On coding benchmarks, V4-Flash lands around <strong>79% on SWE-bench Verified</strong> and <strong>96% on HumanEval</strong> in third-party reporting, with a gap of about 1.6 percentage points to V4-Pro on SWE-bench Verified and around 1.9 points on LiveCodeBench. That is not the kind of gap I care about for the kind of work I do with it.</p>\n<p>The other important fact is that V4-Flash ships under an <strong>MIT license for the weights</strong>. The API name is <code>deepseek-v4-flash</code>, the older <code>deepseek-chat</code> and <code>deepseek-reasoner</code> aliases retire on <strong>July 24, 2026</strong>, and the integration with Pi is officially documented on the DeepSeek API docs, not just tolerated.</p>\n<h2 id=\"the-daily-loop\" tabindex=\"0\" data-toc-anchor=\"true\">The daily loop</h2>\n<p>Here is the loop, condensed.</p>\n<ol>\n<li data-reveal=\"\"><strong>Start the agent in the project root.</strong> <code>pi</code> runs in the terminal, reads the project context, and shows me the current state of the working tree.</li><li data-reveal=\"\"><strong>State the outcome in one paragraph.</strong> Not a task list. A paragraph a smart colleague could act on cold. \"Refactor the search indexer to use a prebuilt JSON dataset. Match the conventions in <code>scripts/cms/_index.js</code>. Do not touch the post template. Return the new file and the minimal diff to wire it up.\"</li><li data-reveal=\"\"><strong>Let the agent run the tool loop.</strong> It reads files, runs <code>pnpm test</code>, runs <code>pnpm lint</code>, edits the right places, and reports back. Most of my sessions are 5 to 20 tool calls.</li><li data-reveal=\"\"><strong>Read the diff.</strong> This is the part I never skip. Pi shows me exactly what changed, in which files, and I approve, reject, or steer.</li><li data-reveal=\"\"><strong>Ship.</strong> Commit, push, let CI do the rest.</li></ol>\n<p>The 1M context window matters more than I expected. Most of my projects are well under that, but the long-context behavior is what makes the agent loop feel cheap rather than expensive. When the model can hold the whole architecture in memory, it stops asking redundant questions, stops re-reading files, and stops producing context-degrading summaries of files it has already read.</p>\n<h2 id=\"the-cost-math-in-real-numbers\" tabindex=\"0\" data-toc-anchor=\"true\">The cost math, in real numbers</h2>\n<p>Let me be specific, because vague cost claims are useless.</p>\n<p>A typical milestone on dout.dev has been something like a CMS build step, a new template, or a content migration. The agent loop for one of those usually looks like:</p>\n<ul>\n<li data-reveal=\"\">200k–500k tokens of input across the session, most of which is the system prompt, the project context, and the files being read.</li><li data-reveal=\"\">5k–20k tokens of output, which includes reasoning, diffs, and the final response.</li><li data-reveal=\"\">Most of those input tokens are <strong>cache hits</strong> after the first turn, because Pi re-sends the same system prompt and the same long project context on every iteration.</li></ul>\n<p>At the cache-miss rate, 500k input tokens cost <strong>$0.07</strong>. 20k output tokens cost <strong>$0.0056</strong>. Total: <strong>$0.0756</strong> before cache.</p>\n<p>At the cache-hit rate for everything except the first turn, the same session lands closer to <strong>$0.02–$0.04</strong>. That is for a milestone that would have taken me hours and a significant amount of attention.</p>\n<p>A heavy day, two milestones plus a refactor plus a documentation pass, has been landing somewhere in the <strong>$0.30–$0.90</strong> range. I have had monthly totals that look like rounding errors. I am not being clever about it. I just do not have to think about the meter.</p>\n<p>There is also <code>pi-deepseek-cache</code>, a small extension that pins the system prompt and tool definitions to keep the prefix-cache hot, and the developer reports a 95%+ cache hit rate once the loop stabilizes. I have not measured my own hit rate that carefully, but I have watched my daily bill drop when I started using it, and the savings are real.</p>\n<h2 id=\"the-closed-source-trap-i-walked-away-from\" tabindex=\"0\" data-toc-anchor=\"true\">The closed-source trap I walked away from</h2>\n<p>Let me be direct, because the marketing is designed to obscure this.</p>\n<p><strong>Cursor</strong> is a VS Code fork with proprietary extensions glued to someone else's APIs. You do not own the loop, you do not own the integrations, and you pay a per-month subscription that rises without your consent. The model is venture-funded price suppression: lose money on every seat, make it up on the locked-in base when the music stops. The usual playbook.</p>\n<p><strong>Claude Code</strong> is a genuinely good tool harness owned by Anthropic, which means it exists to sell Anthropic models. You are not the customer of the tool - you are the inventory. The pricing is opaque, the model access is gated, and the open-source contributions are decorative. When Anthropic raises Opus pricing next quarter - and it will - your loop cost triples and you have no alternative provider to switch to within the same harness. That is not a product. That is <strong>addiction by design</strong>: low-dose introductory offer, price escalates after the habit forms.</p>\n<p><strong>ChatGPT, GitHub Copilot, Codex</strong> - all proprietary. All trained on public data the companies would never let you train on. All designed to make you dependent on a closed API that can change terms, pricing, or access at any time with zero recourse. The open-weight models from DeepSeek, Mistral, and Llama are structurally more aligned with your interest as a developer: you can run them, fork them, audit them, and switch between them without asking for permission.</p>\n<p><strong>OpenCode</strong> and the emerging ecosystem of open-agent toolkits are moving in the right direction: tools that assume you want transparency, portability, and the freedom to change the model without changing the harness. That is the principle that matters. Not \"AI for everyone\" as a slogan, but <strong>\"AI you control\"</strong> as a property of the software.</p>\n<p>Pi is the only one of these that is open source at its core - MIT-licensed from day one, with a package registry where anyone can publish an extension without a review board or a commercial agreement. My <code>@pixu1980/pi-path-picker</code> is a small example, but the fact that I can write it, publish it, and use it without asking anyone is the entire difference between a platform and a prison.</p>\n<h3 id=\"the-extension-potential-is-genuinely-infinite\" tabindex=\"0\" data-toc-anchor=\"true\">The extension potential is genuinely infinite</h3>\n<p>The most surreal part of this setup is that the model itself can write extensions for the harness that runs it.</p>\n<p>DeepSeek V4 Flash reads the Pi extension documentation - a few pages of TypeScript interfaces and a <code>package.json</code> schema - and generates working extensions on the first try. I have done it. You describe what you want in plain English, the model reads the API docs from the repository, and it produces a complete extension: tool registration, prompt hooks, TUI integration, the whole thing. One prompt.</p>\n<p>That is the loop squared. You use an open-source agent to call an open-weight model, and the model extends the agent while you watch. The harness grows its own capabilities. There is no approval queue, no marketplace gatekeeper, no SDK version lock. You just describe, generate, publish, and use.</p>\n<p>It is incredible, it is satisfying, and it is the most empowering development workflow I have ever experienced. The ceiling is not set by a product manager's roadmap. The ceiling is set by what you can describe in a prompt.</p>\n<h2 id=\"the-honest-trade-offs\" tabindex=\"0\" data-toc-anchor=\"true\">The honest trade-offs</h2>\n<p>Nothing is free. Here is what I give up.</p>\n<ul>\n<li data-reveal=\"\"><strong>V4-Flash is still labeled Preview.</strong> The model is fast, cheap, and very good, but the weights are still in the preview line and the API name change in July 2026 means I will need to update configuration at some point.</li><li data-reveal=\"\"><strong>Pi does not babysit me.</strong> No permission popups, no dry-run confirmation, no safe-mode. If I let the agent run a destructive command, it runs. This is a feature for me, but it is a sharp edge for someone used to a more guided harness.</li><li data-reveal=\"\"><strong>No plan mode out of the box.</strong> I write my own prompt template when I want a planning step. Some people will hate this. I do not, because I find that hardcoded plan modes get in the way of the kind of small, fast, in-the-flow work I do most of the time.</li><li data-reveal=\"\"><strong>The model is Chinese-trained on a large multilingual corpus.</strong> I do not consider this a trade-off, but it is a fact. V4-Flash is excellent at English, very good at the rest of the languages I touch, and explicitly licensed for commercial use.</li><li data-reveal=\"\"><strong>Long-context reasoning still degrades past a point.</strong> The 1M context is a budget, not a free pass. The agent loop is at its best when the relevant files are well within the first 200k–400k tokens, and the rest of the context is supporting material.</li></ul>\n<p>None of these trade-offs are dealbreakers for me. They are constraints I work with.</p>\n<h2 id=\"why-this-is-the-new-default\" tabindex=\"0\" data-toc-anchor=\"true\">Why this is the new default</h2>\n<p>Five years ago, the choice of a coding agent was a tooling preference. Today, it is a budget question, a workflow question, and a philosophical question.</p>\n<p>Pi is a small, transparent, customizable harness that does not try to be my IDE, my project manager, or my safety net. It gives me a loop and gets out of the way. DeepSeek V4 Flash is a fast, open, properly sparse model that charges cents for what used to cost dollars. Together, they make the kind of agent-driven, multi-file, context-heavy work I do every day economically trivial.</p>\n<p>I am not saying Pi + V4-Flash is the right answer for every team. A regulated environment, a large enterprise, a security-sensitive codebase, or a team that needs deep IDE integration will make a different choice. But for an independent developer who ships a lot, who reads every diff, who does not need permission popups, and who used to flinch at the monthly AI bill, the answer is: this loop, this model, this cost.</p>\n<p>The model is becoming part of the abstraction layer. The agent harness is becoming part of the editor. The bill is becoming rounding error.</p>\n<p>That is the new default. I am not going back.</p>\n<h2 id=\"sources\" tabindex=\"0\" data-toc-anchor=\"true\">Sources</h2>\n<ul>\n<li data-reveal=\"\"><a href=\"https://pi.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">pi.dev - official site</a></li><li data-reveal=\"\"><a href=\"https://github.com/earendil-works/pi?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">earendil-works/pi on GitHub</a></li><li data-reveal=\"\"><a href=\"https://api-docs.deepseek.com/news/news260424?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek V4 Preview release notes (April 24, 2026)</a></li><li data-reveal=\"\"><a href=\"https://api-docs.deepseek.com/quick_start/pricing?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek API - Models and pricing</a></li><li data-reveal=\"\"><a href=\"https://api-docs.deepseek.com/quick_start/agent_integrations/pi_mono?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek API - Integrate with Pi</a></li><li data-reveal=\"\"><a href=\"https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">DeepSeek V4 Flash on Hugging Face</a></li><li data-reveal=\"\"><a href=\"https://huggingface.co/blog/deepseekv4?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Hugging Face - DeepSeek V4 blog post</a></li><li data-reveal=\"\"><a href=\"https://hokai.io/hub/models/deepseek-v4-flash?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">HokAI - DeepSeek V4 Flash model profile</a></li><li data-reveal=\"\"><a href=\"https://codersera.com/blog/deepseek-v4-flash-deep-dive/amp/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Codersera - DeepSeek V4 Flash deep dive</a></li><li data-reveal=\"\"><a href=\"https://www.runlocalai.co/models/deepseek-v4-flash?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">RunLocalAI - DeepSeek V4 Flash model profile</a></li><li data-reveal=\"\"><a href=\"https://github.com/rohaquinlop/pi-deepseek-cache?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">rohaquinlop/pi-deepseek-cache on GitHub</a></li><li data-reveal=\"\"><a href=\"https://github.com/TheTrebor/pi-reasonix?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">thetrebor/pi-reasonix on GitHub</a></li><li data-reveal=\"\"><a href=\"https://pick-right.com/tools/pi/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pi Review - Pick Right</a></li><li data-reveal=\"\"><a href=\"https://shenxianpeng.github.io/en/posts/2026/pi-deepseek/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Xianpeng Shen - Writing an Article for Twenty-Four Cents: Pi + DeepSeek</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-20-pi-dev-deepseek-v4-flash-daily-loop.png",
      "date_published": "2026-06-20T00:00:00.000Z",
      "tags": [
        "ai",
        "workflow",
        "tooling",
        "making-of"
      ]
    },
    {
      "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 data-reveal=\"\">No server. No Algolia, no Lunr-as-a-service, no runtime dependency.</li><li data-reveal=\"\">Small payload. A reader who never opens the search page should pay nothing.</li><li data-reveal=\"\">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 data-reveal=\"\"><code>posts.json</code> - one entry per published post: title, slug, date, description, tags, series, keywords extracted from the body;</li><li data-reveal=\"\"><code>tags.json</code> - one entry per tag: label, slug, count;</li><li data-reveal=\"\"><code>months.json</code> - one per month: <code>YYYY-MM</code>, count;</li><li data-reveal=\"\"><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 data-reveal=\"\"><code>q</code> is the query term.</li><li data-reveal=\"\"><code>type</code> is repeatable and filters the result stream (post, tag, series, month).</li><li data-reveal=\"\"><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 data-reveal=\"\"><strong>No search-as-you-type with network calls.</strong> Every keystroke scores against the in-memory dataset, which is already loaded.</li><li data-reveal=\"\"><strong>No analytics on queries.</strong> The site analytics are page hits only (no cookies), and search is intentionally out of scope.</li><li data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><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 data-reveal=\"\"><a href=\"https://www.w3.org/TR/css-mixins-1/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">W3C, \"CSS Functions and Mixins Module\"</a></li></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 data-reveal=\"\">small screens and zoomed layouts;</li><li data-reveal=\"\">reduced motion preferences;</li><li data-reveal=\"\">higher contrast needs;</li><li data-reveal=\"\">transparent surfaces that lower readability;</li><li data-reveal=\"\">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 data-reveal=\"\">Markdown emits semantic code blocks.</li><li data-reveal=\"\">Lexers produce token ranges.</li><li data-reveal=\"\">The CSS Custom Highlight API paints those ranges without spans.</li><li data-reveal=\"\">Fallback markup covers browsers without highlight support.</li><li data-reveal=\"\">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 data-reveal=\"\"><strong>Sass.</strong> Nesting is native. Variables are custom properties. <code>darken()</code> is <code>color-mix()</code>.</li><li data-reveal=\"\"><strong>BEM.</strong> Cascade layers and <code>@scope</code> reduce the need for naming conventions.</li><li data-reveal=\"\"><strong>PostCSS plugins.</strong> Most of what they did is now native or unnecessary.</li><li data-reveal=\"\"><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"
      ]
    }
  ]
}
