<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Danny Holloran</title>
    <description>The latest articles on DEV Community by Danny Holloran (@grimicorn).</description>
    <link>https://dev.to/grimicorn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951431%2Fab822d20-286b-4190-86ce-c6a0bcbb8319.jpeg</url>
      <title>DEV Community: Danny Holloran</title>
      <link>https://dev.to/grimicorn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grimicorn"/>
    <language>en</language>
    <item>
      <title>Invoker Commands: Wiring Buttons to Dialogs and Popovers Without JavaScript</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Thu, 02 Jul 2026 14:09:01 +0000</pubDate>
      <link>https://dev.to/grimicorn/invoker-commands-wiring-buttons-to-dialogs-and-popovers-without-javascript-5agk</link>
      <guid>https://dev.to/grimicorn/invoker-commands-wiring-buttons-to-dialogs-and-popovers-without-javascript-5agk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/invoker-commands-buttons-dialogs-popovers-without-javascript" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every time you wire up a modal, you end up writing the same three lines of JavaScript: grab the button, grab the dialog, attach a click listener that calls &lt;code&gt;showModal()&lt;/code&gt;. Do it again for the close button. Do it again for the next popover, the next dropdown, the next confirmation box. Across a real app, that adds up to a pile of glue code whose only job is connecting one element to another. The Invoker Commands API deletes most of that glue by letting a button say, right in the HTML, what it controls and what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two attributes, no listener
&lt;/h2&gt;

&lt;p&gt;The API adds two attributes to &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt;: &lt;code&gt;commandfor&lt;/code&gt; points at the &lt;code&gt;id&lt;/code&gt; of the element you want to control, and &lt;code&gt;command&lt;/code&gt; names the action. For a dialog, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"show-modal"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Delete&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;dialog&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Delete this item?&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"close"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"confirm"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dialog&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no script here at all. The first button opens the dialog as a modal; the button inside closes it. The browser ships a set of built-in commands for exactly the elements that used to need boilerplate: dialogs get &lt;code&gt;show-modal&lt;/code&gt; and &lt;code&gt;close&lt;/code&gt;, and popovers get &lt;code&gt;toggle-popover&lt;/code&gt;, &lt;code&gt;show-popover&lt;/code&gt;, and &lt;code&gt;hide-popover&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A popover menu is just as terse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"toggle-popover"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"menu"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Menu&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"menu"&lt;/span&gt; &lt;span class="na"&gt;popover&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/profile"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Profile&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/logout"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Log out&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this beats a click handler
&lt;/h2&gt;

&lt;p&gt;The obvious win is less code, but the real win is behavior you would otherwise have to remember to implement. Because the browser owns the interaction, you inherit correct focus management, the &lt;code&gt;Escape&lt;/code&gt;-to-close behavior for dialogs and popovers, and the accessibility relationships between the trigger and its target for free. That is a category of bug (the modal that traps focus wrong, the popover that does not close on outside click) that simply stops happening.&lt;/p&gt;

&lt;p&gt;There is also a deliberate constraint: only &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; can be an invoker. Links and inputs cannot use &lt;code&gt;commandfor&lt;/code&gt;, because a command is an action, and buttons are the element with the right keyboard and semantic behavior for actions. And since the wiring lives in markup, it works the moment the HTML parses, before any JavaScript hydrates. For server-rendered pages and islands-style architectures, that means your dialogs and menus are interactive without waiting on a bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom commands for your own behavior
&lt;/h2&gt;

&lt;p&gt;Built-ins cover dialogs and popovers, but the API is not limited to them. You can define your own command with a double-dash prefix, following the same "dashed-ident" convention as CSS custom properties. The &lt;code&gt;--&lt;/code&gt; namespace is reserved: the browser guarantees it will never ship a built-in command starting with it, so your names can never collide with a future addition.&lt;/p&gt;

&lt;p&gt;A custom command fires a &lt;code&gt;command&lt;/code&gt; event on the &lt;em&gt;target&lt;/em&gt; element, and you listen there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"--rotate-left"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Rotate left&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;command=&lt;/span&gt;&lt;span class="s"&gt;"--reset"&lt;/span&gt; &lt;span class="na"&gt;commandfor=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reset&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"photo"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/cat.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"A cat, upright"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;photo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;command&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--rotate-left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// rotate and update alt text&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// reset&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// event.source is the button that triggered the command&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha worth internalizing: the &lt;code&gt;command&lt;/code&gt; event does &lt;strong&gt;not&lt;/strong&gt; bubble. You have to listen on the target element itself, not on a shared ancestor, so event delegation patterns will not pick it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where support stands
&lt;/h2&gt;

&lt;p&gt;This is not a "someday" feature. As of late 2025 the Invoker Commands API reached Baseline across Chrome and Edge (135+), Firefox, and Safari, so you can use it in production for the browsers most projects target. If you still support older versions, it degrades cleanly: a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; with &lt;code&gt;command&lt;/code&gt;/&lt;code&gt;commandfor&lt;/code&gt; is just a button where the attributes are ignored, so a small click-handler fallback (or a feature check like &lt;code&gt;"command" in HTMLButtonElement.prototype&lt;/code&gt;) keeps things working everywhere.&lt;/p&gt;

&lt;p&gt;The practical rule of thumb: reach for &lt;code&gt;command&lt;/code&gt; and &lt;code&gt;commandfor&lt;/code&gt; first for dialogs, popovers, and simple UI toggles, and only drop down to JavaScript when you need genuinely custom behavior, which the custom-command event gives you a clean hook for. You end up with fewer listeners, less hydration-order fragility, and accessibility handled by the platform instead of by you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/invoker-commands-buttons-dialogs-popovers-without-javascript" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webapis</category>
      <category>html</category>
      <category>a11y</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Fallow: A Codebase Truth Layer for Agent-Written Code</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Wed, 01 Jul 2026 19:29:44 +0000</pubDate>
      <link>https://dev.to/grimicorn/fallow-a-codebase-truth-layer-for-agent-written-code-3gk4</link>
      <guid>https://dev.to/grimicorn/fallow-a-codebase-truth-layer-for-agent-written-code-3gk4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/fallow-a-codebase-truth-layer-for-agent-written-code" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most of the code in my side projects lately wasn't typed by me. I write the issue, an agent writes the branch, and I review the PR. That workflow is fast, but it quietly moves the hard part downstream. When you review a lot of generated diffs, you stop worrying about whether a function works and start worrying about the stuff a diff never shows you: the export nothing imports anymore, the near-identical helper that already exists two folders over, the module that just started reaching across a boundary it had no business touching.&lt;/p&gt;

&lt;p&gt;ESLint and Prettier don't catch any of that, because they can't. A linter reads one file at a time. The problems I actually run into are relationships between files, and that's exactly the gap &lt;a href="https://github.com/fallow-rs/fallow" rel="noopener noreferrer"&gt;Fallow&lt;/a&gt; fills.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fallow actually checks
&lt;/h2&gt;

&lt;p&gt;Fallow calls itself a "codebase truth layer," and the framing is accurate. Instead of linting files, it builds a module graph across your whole TypeScript/JavaScript project and answers questions no file-local tool can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dead code&lt;/strong&gt; — unused files, exports, dependencies, types, and circular dependencies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplication&lt;/strong&gt; — copy-pasted blocks, from exact matches to semantic clones with renamed variables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity&lt;/strong&gt; — the riskiest functions and the files worth refactoring first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture drift&lt;/strong&gt; — imports that cross layer or module boundaries they shouldn't&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pitch that sold me is the honest one on the README: "Built for AI-assisted development. No AI inside." It's a Rust binary, it's deterministic, and it's fast enough that speed never becomes an excuse to skip it. On a 20,000-file Next.js project it finishes dead-code analysis in under two seconds. On anything I'm building solo, it's basically instant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx fallow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Dead code   3 unused files, 12 unused exports, 2 unused deps       18ms
 Duplication 4 clone groups (2.1% of codebase)                      31ms
 Complexity  7 functions exceed thresholds                           4ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No config for the first run. It ships 90 framework plugins, so it already knows what an entry point looks like in Next, Nuxt, SvelteKit, Astro, and the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits with my other guardrails
&lt;/h2&gt;

&lt;p&gt;Fallow didn't replace anything in my setup. It filled the one hole the others left.&lt;/p&gt;

&lt;p&gt;The way I think about it now, each tool owns a different scale. Prettier owns formatting. ESLint owns file-local correctness. My &lt;code&gt;CLAUDE.md&lt;/code&gt; (or &lt;code&gt;AGENTS.md&lt;/code&gt;) owns intent: a handful of rules I want every generated change to respect, like rule of three before you abstract, early returns over nested conditionals, and keep functions small. Those guidelines shape the code as the agent writes it. Fallow owns the part none of the others can see, which is what the change did to the codebase as a whole.&lt;/p&gt;

&lt;p&gt;The command that ties it into review is &lt;code&gt;audit&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx fallow audit &lt;span class="nt"&gt;--format&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It scopes dead code, duplication, and complexity to just the changed files and returns a verdict: pass, warn, or fail, with a real exit code. That runs cleanly in CI, but it's just as useful the moment before I open a PR. If the agent left an orphaned export or quietly duplicated a utility, I see it as a fact instead of hoping I'll spot it by eye at line 300 of a diff.&lt;/p&gt;

&lt;p&gt;Because the JSON output includes a per-issue &lt;code&gt;actions&lt;/code&gt; array, the agent itself can consume it. The loop becomes: generate the change, run &lt;code&gt;fallow --format json&lt;/code&gt;, read the findings, fix the obvious ones, and hand me a cleaner PR to review. There's an MCP server for wiring it directly into Claude Code, Cursor, and friends, but honestly the CLI plus a line in the agent instructions gets you most of the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;Running it once costs nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx fallow            &lt;span class="c"&gt;# dead code + duplication + health&lt;/span&gt;
fallow dead-code      &lt;span class="c"&gt;# just cleanup candidates&lt;/span&gt;
fallow health &lt;span class="nt"&gt;--score&lt;/span&gt; &lt;span class="c"&gt;# project health score, 0-100&lt;/span&gt;
fallow fix &lt;span class="nt"&gt;--dry-run&lt;/span&gt;  &lt;span class="c"&gt;# preview automatic cleanup&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you're ready to make it stick, &lt;code&gt;fallow init&lt;/code&gt; writes a tailored &lt;code&gt;.fallowrc.json&lt;/code&gt; and can scaffold a pre-commit hook. Start every rule at &lt;code&gt;warn&lt;/code&gt; so it surfaces problems without blocking you, then promote the ones you care about to &lt;code&gt;error&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unused-files"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unused-exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In CI it's a one-liner (&lt;code&gt;uses: fallow-rs/fallow@v2&lt;/code&gt;), and the &lt;code&gt;--baseline&lt;/code&gt; flag means you only fail on &lt;em&gt;new&lt;/em&gt; issues, which makes it painless to adopt on a codebase that's already a little messy.&lt;/p&gt;

&lt;p&gt;Delegating code to agents doesn't remove the need for review. It just changes what review is about. Fallow gives me a factual read on what a change did to the whole project, so I can spend my attention on the parts that actually need judgment instead of playing spot-the-dead-export. If you're shipping code you didn't personally type, that's a trade worth making.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/fallow-a-codebase-truth-layer-for-agent-written-code" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>fallow</category>
    </item>
    <item>
      <title>The Navigation API: Stop Wrestling with history.pushState</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 29 Jun 2026 16:47:10 +0000</pubDate>
      <link>https://dev.to/grimicorn/the-navigation-api-stop-wrestling-with-historypushstate-3i5h</link>
      <guid>https://dev.to/grimicorn/the-navigation-api-stop-wrestling-with-historypushstate-3i5h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/the-navigation-api-stop-wrestling-with-history-pushstate" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If you have ever hand-rolled a router, you know the ritual. You call &lt;code&gt;history.pushState&lt;/code&gt;, then you wire up a &lt;code&gt;popstate&lt;/code&gt; listener, then you discover &lt;code&gt;popstate&lt;/code&gt; does not fire for &lt;code&gt;pushState&lt;/code&gt; so you patch the URL change separately, then you intercept every link click yourself, then you give up and reach for a framework router. The History API was never designed for single-page apps. It was a thin patch bolted onto multi-page navigation, and we have spent more than a decade working around its gaps.&lt;/p&gt;

&lt;p&gt;The Navigation API is the fix, and as of early 2026 it is finally something you can ship. It reached Baseline Newly Available in January 2026, with support in Chrome, Edge, Firefox 147, and Safari 26.2. That means a centralized, purpose-built interface for client-side routing now works across every major engine on desktop and mobile.&lt;/p&gt;

&lt;h2&gt;
  
  
  One event to rule every navigation
&lt;/h2&gt;

&lt;p&gt;The core idea is a single &lt;code&gt;navigate&lt;/code&gt; event on the global &lt;code&gt;navigation&lt;/code&gt; object. It fires for &lt;em&gt;every&lt;/em&gt; kind of navigation: a link click, a form submission, a programmatic call, even a back/forward button press. Instead of scattering click handlers across your app and praying you caught them all, you handle routing in one place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;navigation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;navigate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Let the browser handle anything we shouldn't touch.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;shouldNotIntercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/articles/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;renderPlaceholder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getArticleContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;renderArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;intercept({ handler })&lt;/code&gt; call is the heart of it. It tells the browser "I am taking over this navigation, it is a same-document update, and it may take a moment." If your handler returns a promise (which an &lt;code&gt;async&lt;/code&gt; function does automatically), the browser knows exactly when the navigation starts, finishes, or fails. That is a real semantic the platform understands now, which is why Chrome shows its native loading indicator and enables the stop button during your async route change. You get accessibility wins for free that you simply could not get by mutating &lt;code&gt;history&lt;/code&gt; by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decisions the old API made you guess at
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;navigateEvent&lt;/code&gt; carries the context you used to reconstruct manually. A small guard function keeps you from intercepting navigations you have no business touching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldNotIntercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canIntercept&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="c1"&gt;// cross-origin, etc.&lt;/span&gt;
    &lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hashChange&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="c1"&gt;// same-page anchor jump&lt;/span&gt;
    &lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;downloadRequest&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="c1"&gt;// it's a download&lt;/span&gt;
    &lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="c1"&gt;// a POST form submission&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canIntercept&lt;/code&gt; tells you up front whether interception is even allowed (cross-origin navigations and cross-document traversals are off limits). &lt;code&gt;hashChange&lt;/code&gt; flags in-page anchor links you should leave alone. &lt;code&gt;downloadRequest&lt;/code&gt; is set for &lt;code&gt;download&lt;/code&gt; links. And &lt;code&gt;formData&lt;/code&gt; is non-null for form POSTs, so checking it lets you handle GET navigations only. Each of these used to be a fragile inference; now they are properties on the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async routes that cancel themselves
&lt;/h2&gt;

&lt;p&gt;Because handlers are async, a user can start one navigation and then fire another before the first finishes. The History API gave you nothing here. The Navigation API hands you an &lt;code&gt;AbortSignal&lt;/code&gt; on the event that fires the moment your navigation becomes redundant, and you can pipe it straight into &lt;code&gt;fetch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;renderPlaceholder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/content?path=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;navigateEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;renderArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user clicks a different link mid-load, the in-flight request is cancelled, the promise rejects, and your stale DOM update never runs. No race conditions, no flicker of the wrong page.&lt;/p&gt;

&lt;p&gt;Beyond routing, the API also exposes the full history as &lt;code&gt;navigation.entries()&lt;/code&gt;, a clean &lt;code&gt;navigation.currentEntry&lt;/code&gt;, and methods like &lt;code&gt;navigation.navigate(url)&lt;/code&gt;, &lt;code&gt;navigation.back()&lt;/code&gt;, and &lt;code&gt;navigation.traverseTo(key)&lt;/code&gt; that all return objects you can &lt;code&gt;await&lt;/code&gt;. It is the API the web should have had all along.&lt;/p&gt;

&lt;p&gt;You probably will not rip out your framework's router tomorrow, and you may still want a tiny polyfill for the long tail of old browsers. But if you are building something custom, or you just want to understand what the next generation of routers is built on, the Navigation API is worth an afternoon. Start with the &lt;a href="https://developer.chrome.com/docs/web-platform/navigation-api" rel="noopener noreferrer"&gt;Chrome for Developers guide&lt;/a&gt; and the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API" rel="noopener noreferrer"&gt;MDN reference&lt;/a&gt;, then go delete some &lt;code&gt;popstate&lt;/code&gt; code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/the-navigation-api-stop-wrestling-with-history-pushstate" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webapis</category>
      <category>routing</category>
      <category>spa</category>
    </item>
    <item>
      <title>Grimicorn Neon: When a Calm Theme Goes Loud</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sun, 28 Jun 2026 21:17:47 +0000</pubDate>
      <link>https://dev.to/grimicorn/grimicorn-neon-when-a-calm-theme-goes-loud-1j20</link>
      <guid>https://dev.to/grimicorn/grimicorn-neon-when-a-calm-theme-goes-loud-1j20</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/grimicorn-neon-when-a-calm-theme-goes-loud" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The original Grimicorn was an exercise in restraint: muted pastels on a blue-gray base, tuned so nothing on screen ever burns your eyes. Grimicorn Neon is the opposite impulse. Same grim-reaper-meets-unicorn idea, except this one is plugged into the mains — saturated, glowing accents on a near-black base, dark-only, loud on purpose.&lt;/p&gt;

&lt;p&gt;What makes the pair interesting from an engineering angle is how little had to change to get there. Neon is not a new theme so much as the same theme with the volume knob turned all the way up. That only works because of a decision made back in the calm version: colors are defined by role, not by appearance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eight roles, eight louder hexes
&lt;/h2&gt;

&lt;p&gt;Both themes share the exact same role map. Blue is keywords, links, and the primary accent. Green is strings, success, and the cursor. Yellow is types, decorators, and warnings. The accent hierarchy is still blue → purple → teal, and the semantic anchors still hold: green means good, the error color means wrong.&lt;/p&gt;

&lt;p&gt;What changes is only the values bound to those roles. Calm Grimicorn's blue is a soft &lt;code&gt;#83AFE5&lt;/code&gt;; Neon's is an electric &lt;code&gt;#2323FF&lt;/code&gt;. Calm green is a sage &lt;code&gt;#A9CE93&lt;/code&gt;; Neon green is an acid &lt;code&gt;#A3E635&lt;/code&gt;. The error role even swaps identity, from a gentle salmon to a hot &lt;code&gt;#FF2D9B&lt;/code&gt; pink. Lay the two palettes side by side and the structure is identical — only the saturation and brightness move:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// same eight roles, two personalities&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#83AFE5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;green&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#A9CE93&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#DD9787&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#253039&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;neon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#2323FF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;green&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#A3E635&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#FF2D9B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#0A0A0B&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because every one of the fourteen tool ports — VS Code, Ghostty, Obsidian, Claude Code, JetBrains, tmux, and the rest — is generated from a single &lt;code&gt;palette.md&lt;/code&gt;, producing the neon set was mostly a matter of feeding the build a different eight values. The emitters that translate roles into VS Code scopes or ANSI slots never knew the difference. That is the real payoff of role-based theming: one architecture, two completely different moods, no forked codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The near-black base is doing real work
&lt;/h2&gt;

&lt;p&gt;Neon accents only read as neon if the thing behind them gets out of the way. Calm Grimicorn could afford a blue-gray base because its accents were gentle. Crank the accents to full saturation and that same base would turn into mud — everything competing, nothing glowing.&lt;/p&gt;

&lt;p&gt;So Neon drops the floor to near-black. The background scale runs from &lt;code&gt;#050506&lt;/code&gt; through &lt;code&gt;#303036&lt;/code&gt;, six steps that are almost monochrome by design. The chrome recedes, and the only saturated pixels left on screen are the ones carrying meaning: a keyword, a string, an error. High contrast plus near-zero background saturation is what produces the "glow." It is the same trick a concert uses — a dark room is what lets the lights pop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loud is a tradeoff, not a free upgrade
&lt;/h2&gt;

&lt;p&gt;Worth being honest here: maximum-saturation text on a black background is the highest-contrast pairing you can build, and that is not purely a win. On dark UIs, very bright, very saturated glyphs can bloom slightly at their edges — a halation effect that some eyes, especially astigmatic ones, read as fuzziness after a long session. That is exactly the fatigue the calm variant was designed to avoid, which is why Neon ships dark-only and is framed as a mood rather than a default. It is a theme for when you want your screen to feel alive, not for the eighth straight hour of debugging.&lt;/p&gt;

&lt;p&gt;That is also why both themes exist instead of one. Calm is the everyday driver; Neon is the rave you opt into. Same roles, same pipeline, same fourteen tools — one tuned to disappear, the other tuned to shout.&lt;/p&gt;

&lt;p&gt;If you want to plug in, every tool gets a single neon file, and you can grab one port or the whole set from the &lt;a href="https://danholloran.me/themes/grimicorn-neon" rel="noopener noreferrer"&gt;Grimicorn Neon page&lt;/a&gt;. The calm original is one click away if your eyes need the quiet back.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/grimicorn-neon-when-a-calm-theme-goes-loud" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>css</category>
      <category>a11y</category>
      <category>typescript</category>
    </item>
    <item>
      <title>scheduler.yield(): The One-Liner That Fixes Your INP</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sun, 28 Jun 2026 17:20:20 +0000</pubDate>
      <link>https://dev.to/grimicorn/scheduleryield-the-one-liner-that-fixes-your-inp-4342</link>
      <guid>https://dev.to/grimicorn/scheduleryield-the-one-liner-that-fixes-your-inp-4342</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/scheduler-yield-the-one-liner-that-fixes-your-inp" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You click a button, and for a beat nothing happens. The spinner doesn't appear, the page feels stuck, and then everything updates at once. That little hitch is exactly what Interaction to Next Paint measures, and the usual culprit is a long task: a chunk of JavaScript that hogs the main thread so the browser can't paint or respond to input until it finishes.&lt;/p&gt;

&lt;p&gt;The old advice was to slice that work into smaller pieces with &lt;code&gt;setTimeout&lt;/code&gt;, handing control back to the browser between chunks. It works, but it comes with a tax. &lt;code&gt;scheduler.yield()&lt;/code&gt; does the same job without the tax, and it's now available across most browsers. If you've been ignoring it, this is the post that should change your mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why setTimeout yielding hurts
&lt;/h2&gt;

&lt;p&gt;The classic pattern looks like this. You're processing a big array, so you periodically yield:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;doExpensiveWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This breaks the long task into shorter ones, which is genuinely good for input delay. The problem is &lt;em&gt;where&lt;/em&gt; your continuation lands. When you yield with &lt;code&gt;setTimeout&lt;/code&gt;, the rest of your function goes to the &lt;strong&gt;back&lt;/strong&gt; of the task queue. Anything else that got scheduled in the meantime, a third-party analytics callback, another component's work, a different &lt;code&gt;setTimeout&lt;/code&gt;, now runs before you get control back. Your loop can stall behind work you don't care about, and a job that should take 100ms stretches out unpredictably.&lt;/p&gt;

&lt;p&gt;You yielded to be polite, and the browser took you a little too literally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What scheduler.yield() does differently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scheduler.yield()&lt;/code&gt; returns a promise you can await. Execution pauses at that point and hands the main thread back, exactly like the &lt;code&gt;setTimeout&lt;/code&gt; trick, so pending interactions can be serviced. The difference is the continuation gets put in a &lt;strong&gt;prioritized&lt;/strong&gt; queue. When the browser comes back around, your function resumes &lt;em&gt;before&lt;/em&gt; other similar tasks that were waiting, rather than after them.&lt;/p&gt;

&lt;p&gt;Rewriting the loop is almost anticlimactic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;doExpensiveWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same shape, better behavior. You still let high-priority interaction work jump the line, the whole point of yielding, but your own continuation isn't shoved to the end of an unbounded queue. In Chrome's framing, a &lt;code&gt;scheduler.yield()&lt;/code&gt; continuation outranks a &lt;code&gt;scheduler.postTask()&lt;/code&gt; task of the same priority level, which is what keeps your loop from getting starved.&lt;/p&gt;

&lt;p&gt;A common real-world shape is an event handler that needs to show feedback before doing slow work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showSpinner&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// let the browser paint the spinner&lt;/span&gt;
  &lt;span class="nf"&gt;doSlowContentSwap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// then run the expensive part&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the yield, the spinner and the slow swap are one long task, so the spinner never actually appears until the work is already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it without breaking Safari
&lt;/h2&gt;

&lt;p&gt;The honest caveat: &lt;code&gt;scheduler.yield()&lt;/code&gt; is a Chromium feature. It's been stable in Chrome and Edge since version 129 (September 2024) and covers roughly 70% of global traffic, but Safari and Firefox don't ship it yet, so you can't call it blind. Feature-detect and fall back.&lt;/p&gt;

&lt;p&gt;A tidy inline fallback covers you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;yieldToMain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;await yieldToMain()&lt;/code&gt; gives you the prioritized continuation where it's supported and the plain &lt;code&gt;setTimeout&lt;/code&gt; behavior everywhere else. Non-Chromium users still get the benefit of broken-up tasks; they just don't get the queue-jumping bonus.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-roll it, Google Chrome Labs publishes a &lt;code&gt;scheduler-polyfill&lt;/code&gt; package that implements the whole Scheduler API. It backs &lt;code&gt;scheduler.yield()&lt;/code&gt; with &lt;code&gt;user-blocking&lt;/code&gt; &lt;code&gt;postTask()&lt;/code&gt; tasks where available and falls through to &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;MessageChannel&lt;/code&gt;, and &lt;code&gt;requestIdleCallback&lt;/code&gt; otherwise, so you get consistent semantics across browsers from a single import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to actually reach for it
&lt;/h2&gt;

&lt;p&gt;This isn't a sprinkle-everywhere API. The wins come from places where you genuinely run long synchronous-ish work in response to input: rendering a large list after a click, parsing or transforming a big payload, hydrating a heavy widget, or running a sequence of independent setup steps on load. Drop a yield between logical chunks and measure INP before and after with something like the web-vitals library or your RUM tooling.&lt;/p&gt;

&lt;p&gt;The pattern to internalize is simple: when a single task does too much, slice it, and when you slice it, yield with &lt;code&gt;scheduler.yield()&lt;/code&gt; rather than &lt;code&gt;setTimeout&lt;/code&gt; so your own work doesn't pay the price for being considerate. It's one of the rare performance fixes that's both a real improvement and a one-line diff.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/scheduler-yield-the-one-liner-that-fixes-your-inp" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>webapis</category>
    </item>
    <item>
      <title>Building Grimicorn: One Palette, Fourteen Tools</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sat, 27 Jun 2026 16:43:48 +0000</pubDate>
      <link>https://dev.to/grimicorn/building-grimicorn-one-palette-fourteen-tools-lj0</link>
      <guid>https://dev.to/grimicorn/building-grimicorn-one-palette-fourteen-tools-lj0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/building-grimicorn-one-palette-fourteen-tools" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A typical day moves through a lot of windows. Editor, terminal, a git client, a notes app, and lately an agentic coding tool or two. Each one ships its own default theme, and even when you pick a "good" one in each, the seams show: the blue that means &lt;em&gt;keyword&lt;/em&gt; in your editor means &lt;em&gt;directory&lt;/em&gt; in your shell and &lt;em&gt;link&lt;/em&gt; in your notes. Your eyes re-learn the color language every time you switch context. It is a small tax, but you pay it hundreds of times a day.&lt;/p&gt;

&lt;p&gt;Grimicorn started as a fix for that tax. It is a calm, low-fatigue color theme — the name is grim reaper × unicorn, dead serious but secretly colorful — built on a muted blue-gray base with soft pastel syntax. The real work, though, was not picking the colors. It was making one palette behave identically across fourteen different tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Color by role, not by name
&lt;/h2&gt;

&lt;p&gt;The mistake most themes make is thinking in colors instead of roles. "I'll use teal here because it looks nice" is how you end up with a teal that means three unrelated things in three places.&lt;/p&gt;

&lt;p&gt;Grimicorn is built around eight core roles, and each role owns a job everywhere it appears. Blue (&lt;code&gt;#83AFE5&lt;/code&gt;) is keywords, links, and the primary accent. Green (&lt;code&gt;#A9CE93&lt;/code&gt;) is strings, success, and the cursor. Salmon (&lt;code&gt;#DD9787&lt;/code&gt;) is errors, invalid input, and deletions. Yellow (&lt;code&gt;#DADA93&lt;/code&gt;) is types, decorators, and warnings. The semantic mapping never drifts: green is always "this is good," salmon is always "this is wrong," and accents follow a strict hierarchy of blue → purple → teal so the most important thing on screen is always the same hue.&lt;/p&gt;

&lt;p&gt;Once colors are defined by role, porting becomes a translation problem rather than a taste problem. A VS Code &lt;code&gt;tokenColors&lt;/code&gt; entry for "string" and a Ghostty ANSI green slot are the same decision wearing different clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  One source of truth, many targets
&lt;/h2&gt;

&lt;p&gt;Fourteen tools means fourteen wildly different file formats. VS Code wants JSON with scopes. iTerm2 wants an XML plist of &lt;code&gt;srgb&lt;/code&gt; components. tmux wants a config script. Ghostty and Warp want their own flavors of key-value. Maintaining those by hand would guarantee drift — fix a color in one, forget the other thirteen.&lt;/p&gt;

&lt;p&gt;So every file is generated from a single source of truth: a &lt;code&gt;grimicorn-palette.md&lt;/code&gt; that defines each role once. A small build step reads that palette and emits each target format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#83AFE5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// keywords · links · primary&lt;/span&gt;
  &lt;span class="na"&gt;green&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#A9CE93&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// strings · success · cursor&lt;/span&gt;
  &lt;span class="na"&gt;salmon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#DD9787&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// errors · invalid · deletions&lt;/span&gt;
  &lt;span class="c1"&gt;// ...eight roles total&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toVSCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;palette&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor.foreground&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;tokenColors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keyword&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;green&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool gets its own emitter — &lt;code&gt;toVSCode&lt;/code&gt;, &lt;code&gt;toGhostty&lt;/code&gt;, &lt;code&gt;toITerm&lt;/code&gt;, &lt;code&gt;toTmux&lt;/code&gt; — but they all read the same eight values. Change &lt;code&gt;green&lt;/code&gt; once and every one of the fourteen ports regenerates with the new value. That is the whole reason the set stays coherent: there is no place for a stray hex code to hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning for low fatigue
&lt;/h2&gt;

&lt;p&gt;The "calm" part is not a vibe; it is a constraint. Nothing in the palette is saturated enough to vibrate against the background. The dark variant rests the same eight roles on a muted blue-gray base; the light variant shifts them toward ink-on-paper without changing what any role &lt;em&gt;means&lt;/em&gt;. A six-step background scale (&lt;code&gt;#1E2A31&lt;/code&gt; through &lt;code&gt;#4E5C66&lt;/code&gt;) gives panels, gutters, and selections somewhere to sit without resorting to pure black or harsh borders.&lt;/p&gt;

&lt;p&gt;Shipping a light variant from the same source is where role-based design pays off again. Light mode is not a separate theme — it is the same role map with each color re-tuned for a bright surface. Blue darkens from &lt;code&gt;#83AFE5&lt;/code&gt; to &lt;code&gt;#4A80C8&lt;/code&gt; so it still reads as the primary accent against paper. Because the roles are fixed, dark and light stay in sync by construction.&lt;/p&gt;

&lt;p&gt;The payoff is the thing you stop noticing. Switch from the editor to the terminal to your notes and the color language holds. A string is the same green in all three. Nothing flares. After enough late nights, that quiet is worth more than any single pretty color.&lt;/p&gt;

&lt;p&gt;If you want to try it, every tool ships a dark and a light file, and you can grab one port or the whole set from the &lt;a href="https://danholloran.me/themes/grimicorn" rel="noopener noreferrer"&gt;Grimicorn theme page&lt;/a&gt; — all generated, as promised, from that one palette.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/building-grimicorn-one-palette-fourteen-tools" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>css</category>
      <category>typescript</category>
      <category>a11y</category>
    </item>
    <item>
      <title>GraphQL Fragments: Let Each Component Own Its Data</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Thu, 25 Jun 2026 16:59:10 +0000</pubDate>
      <link>https://dev.to/grimicorn/graphql-fragments-let-each-component-own-its-data-5359</link>
      <guid>https://dev.to/grimicorn/graphql-fragments-let-each-component-own-its-data-5359</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/graphql-fragments-let-each-component-own-its-data" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;A GraphQL query looks clean when you first write it. One request, all the data you need, no waterfall. Then the app grows. The top-level page query starts picking up fields for five different child components. You add an avatar URL for a new &lt;code&gt;&amp;lt;UserBadge&amp;gt;&lt;/code&gt; and six weeks later you remove the component but forget the field. Nobody knows what's safe to delete. The query keeps growing.&lt;/p&gt;

&lt;p&gt;Fragments are the tool GraphQL gives you to fix this — and most teams underuse them, treating them as a shorthand for copy-pasting field lists rather than as a component ownership model.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fragments Actually Are
&lt;/h2&gt;

&lt;p&gt;A fragment is a named, reusable selection of fields scoped to a specific type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;fragment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UserBadge&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;avatarUrl&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You spread it into any query or other fragment that selects from &lt;code&gt;User&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GetPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;UserBadge&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the basic syntax. The real value comes from where you put the fragment definition — not in a shared &lt;code&gt;fragments.ts&lt;/code&gt; file, but in the same file as the component that uses the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Co-location Pattern
&lt;/h2&gt;

&lt;p&gt;Co-location means each component declares the fragment it needs right alongside its JSX. Here's what that looks like with Apollo Client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;gql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useFragment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@apollo/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Declared next to the component that owns this data&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;USER_BADGE_FRAGMENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gql&lt;/span&gt;&lt;span class="s2"&gt;`
  fragment UserBadge on User {
    id
    name
    avatarUrl
  }
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserBadge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userRef&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserBadge$key&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;USER_BADGE_FRAGMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatarUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parent component spreads the fragment without knowing what fields it contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;USER_BADGE_FRAGMENT&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./UserBadge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET_POST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gql&lt;/span&gt;&lt;span class="s2"&gt;`
  query GetPost($id: ID!) {
    post(id: $id) {
      title
      author {
        ...UserBadge
      }
    }
  }
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;USER_BADGE_FRAGMENT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when &lt;code&gt;UserBadge&lt;/code&gt; needs a &lt;code&gt;role&lt;/code&gt; field, you add it to the fragment. The query picks it up automatically. When you delete &lt;code&gt;UserBadge&lt;/code&gt;, you delete its fragment too, and the query shrinks. The parent never had to know about &lt;code&gt;avatarUrl&lt;/code&gt; in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Masking: Enforcing the Boundary
&lt;/h2&gt;

&lt;p&gt;Co-location by convention is good. Data masking makes it a hard rule.&lt;/p&gt;

&lt;p&gt;With masking enabled (available in Apollo Client 3.12+ and built into Relay by design), a component can only access fields it explicitly requested in its own fragment. Even if the network response contains &lt;code&gt;avatarUrl&lt;/code&gt; because another component requested it, your component gets &lt;code&gt;undefined&lt;/code&gt; unless it declared the field itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Without masking: this might work accidentally because a sibling&lt;/span&gt;
&lt;span class="c1"&gt;// component requested avatarUrl in its fragment&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GET_POST&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatarUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// could be undefined tomorrow if UserBadge moves&lt;/span&gt;

&lt;span class="c1"&gt;// With masking + useFragment: TypeScript errors immediately if you&lt;/span&gt;
&lt;span class="c1"&gt;// access a field your fragment didn't declare&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;USER_BADGE_FRAGMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatarUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// always safe — it's in YOUR fragment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The TypeScript types generated from your fragments encode this contract. If you remove &lt;code&gt;avatarUrl&lt;/code&gt; from &lt;code&gt;UserBadge_Fragment&lt;/code&gt;, the TypeScript compiler tells you everywhere you were using it. Refactors that used to require grepping the whole codebase become a type-check run.&lt;/p&gt;

&lt;p&gt;Tools like &lt;code&gt;gql.tada&lt;/code&gt; and &lt;code&gt;graphql-code-generator&lt;/code&gt; with the &lt;code&gt;client-preset&lt;/code&gt; generate these types automatically from your fragment definitions, so the feedback loop is tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Reach for This Pattern
&lt;/h2&gt;

&lt;p&gt;You don't need fragments for a simple app with three queries. The pattern earns its keep when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multiple components read from the same type (User, Product, Post)&lt;/li&gt;
&lt;li&gt;Your page-level queries are getting long and nobody is sure what each field is for&lt;/li&gt;
&lt;li&gt;You've been bitten by "I removed a component but left its fields in the query"&lt;/li&gt;
&lt;li&gt;You want TypeScript safety at the data-access level, not just at the query level&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start by identifying one component that fetches its own fields via a parent query. Extract those fields into a fragment, move the definition next to the component, and spread it from the parent. That single refactor will make the pattern obvious, and you'll naturally apply it as you go.&lt;/p&gt;

&lt;p&gt;GraphQL's promise is that your data fetching reflects exactly what your UI needs. Fragments are what make that promise hold as the UI grows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/graphql-fragments-let-each-component-own-its-data" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>graphql</category>
      <category>typescript</category>
      <category>react</category>
    </item>
    <item>
      <title>CSS @property: Typed, Animatable Custom Properties</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 22 Jun 2026 16:38:58 +0000</pubDate>
      <link>https://dev.to/grimicorn/css-property-typed-animatable-custom-properties-5hdd</link>
      <guid>https://dev.to/grimicorn/css-property-typed-animatable-custom-properties-5hdd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/css-property-typed-animatable-custom-properties" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;There's a quiet frustration that hits every developer who first tries to animate a CSS custom property. You write a clean gradient, put the color stop in a variable, add a &lt;code&gt;transition&lt;/code&gt; — and it snaps. No animation. Just an instant cut from one value to the next.&lt;/p&gt;

&lt;p&gt;The reason is straightforward: the browser doesn't know what &lt;code&gt;--brand-color&lt;/code&gt; is. It's just a string as far as CSS is concerned. You can't interpolate a string. &lt;code&gt;@property&lt;/code&gt; fixes this by letting you register a custom property with a type, an initial value, and an inheritance rule — turning an opaque blob of text into something the browser can actually reason about and animate.&lt;/p&gt;

&lt;p&gt;As of 2026, &lt;code&gt;@property&lt;/code&gt; is Baseline Widely Available. Chrome, Firefox, Safari, and Edge all support it. There's no reason not to use it for any non-trivial design system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Syntax
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;@property&lt;/code&gt; declaration requires three descriptors: &lt;code&gt;syntax&lt;/code&gt;, &lt;code&gt;inherits&lt;/code&gt;, and &lt;code&gt;initial-value&lt;/code&gt; (required unless &lt;code&gt;syntax&lt;/code&gt; is &lt;code&gt;"*"&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@property&lt;/span&gt; &lt;span class="n"&gt;--brand-hue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"&amp;lt;angle&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;220deg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;syntax&lt;/code&gt; is the type. The full list of supported types includes &lt;code&gt;&amp;lt;color&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;length&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;percentage&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;number&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;integer&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;angle&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;time&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;resolution&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;transform-function&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;transform-list&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;image&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt;. You can also combine types with &lt;code&gt;|&lt;/code&gt;, accept a space-separated list with &lt;code&gt;+&lt;/code&gt;, or accept any value with &lt;code&gt;"*"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inherits&lt;/code&gt; is a boolean. Set it to &lt;code&gt;true&lt;/code&gt; if child elements should be able to inherit the value from a parent (like font-related properties). Set it to &lt;code&gt;false&lt;/code&gt; to scope it locally — useful for per-component counters or animation state that shouldn't bleed upward.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;initial-value&lt;/code&gt; is the fallback when no value is set. For most types it's required and must be a computationally independent value — &lt;code&gt;10px&lt;/code&gt; works, &lt;code&gt;calc(var(--base) * 2)&lt;/code&gt; doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Killer Use Case: Animating Gradients
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;@property&lt;/code&gt;, animating a gradient background required JavaScript to interpolate values and update a style attribute on every frame. CSS had no way to do it natively. With &lt;code&gt;@property&lt;/code&gt;, you register the color stops as typed properties and transition them directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@property&lt;/span&gt; &lt;span class="n"&gt;--stop-one&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"&amp;lt;color&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6366f1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@property&lt;/span&gt; &lt;span class="n"&gt;--stop-two&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"&amp;lt;color&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ec4899&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--stop-one&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--stop-two&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;--stop-one&lt;/span&gt; &lt;span class="m"&gt;0.4s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;--stop-two&lt;/span&gt; &lt;span class="m"&gt;0.4s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--stop-one&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0ea5e9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--stop-two&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#22d3ee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser knows &lt;code&gt;--stop-one&lt;/code&gt; is a &lt;code&gt;&amp;lt;color&amp;gt;&lt;/code&gt;, so it can interpolate between &lt;code&gt;#6366f1&lt;/code&gt; and &lt;code&gt;#0ea5e9&lt;/code&gt; across frames. Smooth, GPU-composited, zero JavaScript.&lt;/p&gt;

&lt;p&gt;The same pattern applies to angles for conic gradients, lengths for clip paths, or percentages for color stop positions. Any place you previously needed JS to animate something inside a CSS function, &lt;code&gt;@property&lt;/code&gt; is the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design System Benefits: Type Safety and Scoped Defaults
&lt;/h2&gt;

&lt;p&gt;Beyond animation, &lt;code&gt;@property&lt;/code&gt; adds a layer of predictability to token-based design systems. An unregistered custom property set to an invalid value silently falls back to whatever the inherited value or browser default is — which can cause subtle layout bugs that are hard to trace. A registered property with &lt;code&gt;syntax: '&amp;lt;length&amp;gt;'&lt;/code&gt; ignores an invalid assignment entirely and keeps the &lt;code&gt;initial-value&lt;/code&gt;, which is at least predictable.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;inherits: false&lt;/code&gt; flag is particularly useful for component-scoped state. Say you're building a progress indicator that tracks a &lt;code&gt;--progress&lt;/code&gt; percentage internally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@property&lt;/span&gt; &lt;span class="n"&gt;--progress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"&amp;lt;percentage&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.progress-bar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;--progress&lt;/span&gt; &lt;span class="m"&gt;0.6s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;inherits: false&lt;/code&gt; means each &lt;code&gt;.progress-bar&lt;/code&gt; instance manages its own &lt;code&gt;--progress&lt;/code&gt; independently. Parent values don't leak in, sibling values don't interfere.&lt;/p&gt;

&lt;p&gt;You can also register properties in JavaScript using &lt;code&gt;CSS.registerProperty()&lt;/code&gt;, which accepts the same options as the at-rule but lets you do it conditionally at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;CSS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerProperty&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--theme-angle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;angle&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;initialValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0deg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The at-rule and the JS API are equivalent — use whichever fits your workflow. The at-rule is usually cleaner for static design tokens; the JS API is handy when the property name or initial value needs to be dynamic.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Rule to Check
&lt;/h2&gt;

&lt;p&gt;If a CSS transition or animation on a custom property is snapping instead of interpolating, the missing &lt;code&gt;@property&lt;/code&gt; registration is almost always why. Add the rule, match the type to what you're actually setting, and the transition starts behaving like any built-in property.&lt;/p&gt;

&lt;p&gt;It's a small addition to the stylesheet that unlocks a category of effects that were genuinely impossible in CSS alone just a few years ago.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/css-property-typed-animatable-custom-properties" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>animation</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Turning a Base M4 Mac Mini Into an Always-On Automation Box</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sun, 21 Jun 2026 22:31:53 +0000</pubDate>
      <link>https://dev.to/grimicorn/turning-a-base-m4-mac-mini-into-an-always-on-automation-box-14ih</link>
      <guid>https://dev.to/grimicorn/turning-a-base-m4-mac-mini-into-an-always-on-automation-box-14ih</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/turning-a-base-m4-mac-mini-into-an-always-on-automation-box" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;For the last year, most of my personal automation has lived on my MacBook Air. Blog cross-posting, content ingest, SEO, accessibility audits and more, all of it running as scheduled Claude skills. It worked great until I traveled. Close the lid and everything stops. Hop onto hotel WiFi and a schedule that assumed a stable connection quietly fails. An automation you have to babysit is not really automation.&lt;/p&gt;

&lt;p&gt;So I bought a Mac mini to be the box that never sleeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the base M4 (and a recycled hard drive)
&lt;/h2&gt;

&lt;p&gt;I deliberately bought the cheapest M4 mini Apple sells. This is an experiment, not a commitment, but it is not a blind bet either. I have been running this exact set of skills on a comparably specced M-series MacBook Air, including overnight code automation that works through multiple issues and pull requests while I sleep, and it never broke a sweat. If anything the mini should hold up better: that Air is passively cooled, while the mini has an actual fan, so it can sustain a long overnight run without the thermal throttling a fanless laptop eventually hits. The mini runs around ten scheduled skills today and that number only goes up, but none of them are heavy. The only thing that would push me toward a higher-spec mini or a Studio is running local AI models, and I want to learn what I actually need before paying for it.&lt;/p&gt;

&lt;p&gt;Storage I solved for free. I had an old 2TB external drive sitting in a drawer, so the mini's modest internal SSD holds the system and the external handles anything bulky. For a headless box you rarely touch, a drive hanging off the back is a non-issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it actually always-on
&lt;/h2&gt;

&lt;p&gt;A Mac mini does not stay awake just because it has no battery. macOS still sleeps the display, and if it thinks no monitor is attached it can get weird about how things render. Two fixes.&lt;/p&gt;

&lt;p&gt;First, kill sleep on AC power:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;pmset &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;0 displaysleep 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-c&lt;/code&gt; flag scopes it to wall power, which is the only state a mini is ever in.&lt;/p&gt;

&lt;p&gt;Second, the headless gotcha: with no monitor plugged in, some apps and remote-desktop tools render to a phantom low-resolution screen or refuse to behave. A cheap 4K display emulator (a dummy HDMI plug) makes macOS believe a real monitor is attached, and everything renders at a sane resolution.&lt;/p&gt;

&lt;p&gt;The rest is unglamorous but matters. Enable automatic login so an unattended reboot comes back to a logged-in session instead of stalling at the lock screen. And, a real tradeoff, I left FileVault off. FileVault is excellent for a laptop that can be stolen. On a headless box that needs to boot unattended into a usable state with nobody around to type a password, it gets in the way more than it protects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Controlling it from my phone
&lt;/h2&gt;

&lt;p&gt;The whole point is that this thing runs while I am somewhere else, so I needed solid remote access from an iPhone. The stack that won:&lt;/p&gt;

&lt;p&gt;Tailscale is the foundation. It is a mesh VPN, not just a tunnel. It gives the mini a stable private &lt;code&gt;100.x.y.z&lt;/code&gt; address and a MagicDNS hostname that follow it anywhere, so I am not chasing a changing home IP or poking holes in my router. Every device on my tailnet can reach it directly and encrypted.&lt;/p&gt;

&lt;p&gt;On top of that, two tools. For a shell I use Blink Shell with mosh, not plain SSH. This matters more than I expected. Plain SSH runs over TCP, and the moment your phone switches from WiFi to cellular the connection dies. Mosh survives network changes, so I can kick off a command at home and still have the session alive on the road. Mosh has to be installed on the server too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;mosh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For full desktop access I use Screens over Tailscale to hit the mini's built-in VNC. Same private address, just the GUI instead of a terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one part that didn't migrate
&lt;/h2&gt;

&lt;p&gt;This is roughly the tenth Mac I have set up, counting school and work machines, so the OS-level setup was fast. I scripted most of it into a &lt;code&gt;setup.sh&lt;/code&gt; and scanned my existing Macs to pull over the configs that were already dialed in. That part went smoothly.&lt;/p&gt;

&lt;p&gt;The exception was my Claude schedules. They do not sync across devices, and the timing for when each task fires is not stored in the markdown task files. It lives in a separate task store, so there is no clean export to grab. I ended up recreating the schedules on the mini by hand from the task descriptions. If you run scheduled agent work across machines, know that the schedule itself is the thing that will not come along for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Right now it is an automation box, but that is the boring first job. The plan is to grow it into a proper home server: Home Assistant for the home, NAS duties off that external storage, and a sandbox for self-hosted tools like n8n plus a few local AI experiments. A base mini is a surprisingly capable little anchor for all of it.&lt;/p&gt;

&lt;p&gt;If you have automations you are tired of babysitting, a cheap always-on machine you can reach from your pocket changes how much you trust them. Mine has been worth every dollar of the base model.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/turning-a-base-m4-mac-mini-into-an-always-on-automation-box" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tooling</category>
      <category>automation</category>
      <category>selfhosting</category>
      <category>macmini</category>
    </item>
    <item>
      <title>WCAG 2.2: What Frontend Developers Actually Need to Fix</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Fri, 19 Jun 2026 02:35:02 +0000</pubDate>
      <link>https://dev.to/grimicorn/wcag-22-what-frontend-developers-actually-need-to-fix-37do</link>
      <guid>https://dev.to/grimicorn/wcag-22-what-frontend-developers-actually-need-to-fix-37do</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/wcag-2-2-what-frontend-developers-need-to-fix" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;If accessibility audits still reference WCAG 2.1 at your company, you're behind. WCAG 2.2 became a W3C Recommendation in October 2023 and is now the version that regulators, auditors, and legal teams point to. In the US, the Department of Justice formally adopted WCAG 2.1 AA as the ADA standard — but in practice, WCAG 2.2 is what modern audits check. The European Accessibility Act, which hit full enforcement for digital products in 2025, also tracks WCAG 2.2.&lt;/p&gt;

&lt;p&gt;The good news: WCAG 2.2 is backward compatible. If you're already meeting 2.1 AA, you're not starting over. You have nine new success criteria to address, and several of them are things you probably already knew you should fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Focus Indicators: The Changes That Matter Most
&lt;/h2&gt;

&lt;p&gt;Two of the nine new criteria deal with focus, and they're the ones that trip up the most component libraries and design systems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Focus Not Obscured (SC 2.4.11, Level AA)&lt;/strong&gt; says a focused element can't be &lt;em&gt;entirely&lt;/em&gt; hidden by author-created content. Sticky headers, floating chat widgets, cookie consent banners, and modal overlays are the usual culprits. The rule doesn't require the whole element to be visible — just that it's not completely covered. SC 2.4.12 (Level AAA) goes further and requires the full component to be visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Focus Appearance (SC 2.4.11 at AA, 2.4.13 at AAA)&lt;/strong&gt; finally gives numbers to what a visible focus indicator looks like. At the AA level, the focus indicator must have a perimeter at least as long as the unfocused component's perimeter, and the color change between focused and unfocused states must have a contrast ratio of at least 3:1. The browser default outline in Chrome and Safari passes — &lt;code&gt;outline: none&lt;/code&gt; without a replacement does not.&lt;/p&gt;

&lt;p&gt;In practice, these two criteria mean: never &lt;code&gt;outline: none&lt;/code&gt; without an alternative, and test keyboard navigation on any page that has sticky or fixed-position elements. A quick scroll-and-tab through your interface catches most Focus Not Obscured violations immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Bad — removes focus ring with no replacement */&lt;/span&gt;
&lt;span class="nd"&gt;:focus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Good — custom ring that meets contrast + size requirements */&lt;/span&gt;
&lt;span class="nd"&gt;:focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#005fcc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;:focus-visible&lt;/code&gt; instead of &lt;code&gt;:focus&lt;/code&gt; means keyboard users see the indicator while mouse users aren't distracted by it. All modern browsers support it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Touch Targets and Dragging
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Target Size Minimum (SC 2.5.8, Level AA)&lt;/strong&gt; requires interactive elements to have a target size of at least 24×24 CSS pixels. The rule applies to the target itself, not just the visual rendering — spacing counts if it pushes adjacent targets apart. Buttons, links in navigation, and icon-only controls are the places to check. There are exceptions: inline text links and elements whose size is determined by the browser default (like native checkboxes without custom styling) are exempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dragging Movements (SC 2.5.7, Level AA)&lt;/strong&gt; requires that any functionality using a drag operation has a single-pointer alternative. A sortable list that only works via drag-and-drop fails this criterion unless there's also an up/down button, a cut-and-paste mechanism, or some other way to accomplish the same task without dragging. If you're using a drag library like &lt;code&gt;dnd-kit&lt;/code&gt; or &lt;code&gt;react-beautiful-dnd&lt;/code&gt;, this is worth auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication and Forms
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Accessible Authentication (SC 3.3.8 at AA, 3.3.9 at AAA)&lt;/strong&gt; is the criterion that generated the most discussion when 2.2 dropped. It says a cognitive function test — remembering a password, solving a CAPTCHA, transcribing characters — cannot be required as the &lt;em&gt;only&lt;/em&gt; way to authenticate. The criterion doesn't ban passwords, but it does require that if you use a password, users can paste into the field (so password managers work), autofill is allowed, or a copy mechanism is available.&lt;/p&gt;

&lt;p&gt;In practical terms: don't block paste on password inputs, don't disable autocomplete on login forms, and consider adding magic links or passkeys as alternatives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redundant Entry (SC 3.3.7, Level A)&lt;/strong&gt; says information already provided in a multi-step process shouldn't be required again — unless there's a security or validity reason. A checkout flow that asks for billing address, then asks for the same address again on a review screen, fails this criterion. The fix is either to pre-fill or carry information forward between steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;The fastest way to catch most of these issues is to run your UI through a keyboard-only navigation pass and check with an automated tool like axe or Lighthouse — though automated tools catch only about 30–40% of accessibility issues, so manual testing matters. For WCAG 2.2 specifically, the new criteria around focus, target size, and authentication aren't well-detected by automated scanners yet.&lt;/p&gt;

&lt;p&gt;WCAG 3.0 is on the horizon — a March 2026 Working Draft landed with 174 requirements and a new scoring model that moves away from the binary pass/fail system — but it's realistically 2028–2030 before it's a Recommendation. WCAG 2.2 is what you need to ship against right now.&lt;/p&gt;

&lt;p&gt;The nine new criteria aren't a massive lift if you tackle them systematically. Start with focus indicators (they're the most commonly broken), then work through target sizes and authentication, and you'll be in solid shape for any current accessibility review.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/wcag-2-2-what-frontend-developers-need-to-fix" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>wcag</category>
      <category>html</category>
      <category>css</category>
    </item>
    <item>
      <title>CSS User Preference Media Queries: Build Accessible Experiences Without JavaScript</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:03:20 +0000</pubDate>
      <link>https://dev.to/grimicorn/css-user-preference-media-queries-build-accessible-experiences-without-javascript-4k2g</link>
      <guid>https://dev.to/grimicorn/css-user-preference-media-queries-build-accessible-experiences-without-javascript-4k2g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/css-user-preference-media-queries" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Your users have already told your browser what they need. They've toggled "Reduce Motion" in System Settings, switched their OS to dark mode, or cranked up contrast because their display washes out in sunlight. The question is whether your CSS is listening.&lt;/p&gt;

&lt;p&gt;User preference media queries let you respond to those signals directly in CSS — no &lt;code&gt;window.matchMedia&lt;/code&gt;, no state management, no JavaScript overhead. They've been creeping into browser support for years, but as of 2026 all four major ones are universally supported and underused. Here's how to put them to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;prefers-reduced-motion&lt;/code&gt;: Stop Animating People Into Headaches
&lt;/h2&gt;

&lt;p&gt;Motion on the web can be genuinely harmful. For people with vestibular disorders, autoplay animations and parallax effects can trigger vertigo, nausea, or migraines. The &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query maps directly to the "Reduce Motion" accessibility preference in macOS, Windows, iOS, and Android.&lt;/p&gt;

&lt;p&gt;The pattern is to write your animated default, then strip it back for users who opt out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;slide-in&lt;/span&gt; &lt;span class="m"&gt;0.6s&lt;/span&gt; &lt;span class="n"&gt;ease-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cleaner pattern inverts this — start from no animation and only add it when motion is okay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no-preference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hero-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;slide-in&lt;/span&gt; &lt;span class="m"&gt;0.6s&lt;/span&gt; &lt;span class="n"&gt;ease-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach means users who haven't touched the setting still get animations (&lt;code&gt;no-preference&lt;/code&gt; is the default), but you're making motion opt-in at the code level rather than opt-out. It also makes it easier to audit during a review — every animated element lives inside a &lt;code&gt;no-preference&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;Don't nuke everything with a blanket &lt;code&gt;* { animation: none }&lt;/code&gt; rule. Focus indicators and loading spinners may rely on animation to communicate state. Audit each one and decide whether to remove, slow down, or replace with a static alternative.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;prefers-color-scheme&lt;/code&gt;: Native Dark Mode Without the Toggle
&lt;/h2&gt;

&lt;p&gt;If your app ships its own dark mode toggle, &lt;code&gt;prefers-color-scheme&lt;/code&gt; lets you default to whatever the user's OS is already set to — and it updates instantly when they switch, no page reload required.&lt;/p&gt;

&lt;p&gt;The typical setup pairs it with CSS custom properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f4f4f4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d1d1d1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0f0f0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8e8e8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1c1c1c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2e2e2e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else in your stylesheet uses the custom properties, so theme switching happens in exactly one place. If you want to layer a user-controlled toggle on top, store the override in a &lt;code&gt;data-theme&lt;/code&gt; attribute on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; and let CSS check that first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"light"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0f0f0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8e8e8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;:root:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0f0f0f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8e8e8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No attribute = fall back to the OS setting. User hits the toggle = set the attribute. Clean layering, no JavaScript required for the default path.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;prefers-contrast&lt;/code&gt;: Don't Assume Your Palette Is Readable
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prefers-contrast&lt;/code&gt; responds to Windows' "High Contrast" mode and macOS's "Increase Contrast" accessibility setting. The two useful values are &lt;code&gt;more&lt;/code&gt; (user wants higher contrast) and &lt;code&gt;less&lt;/code&gt; (some users with light sensitivity prefer reduced contrast).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-contrast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;more&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;currentColor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;currentColor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is most valuable for subtle UI elements — muted borders, low-contrast placeholder text, ghost buttons. Those choices look polished on a calibrated display but disappear for users with low vision or in bright environments. A &lt;code&gt;prefers-contrast: more&lt;/code&gt; block can harden those edges without touching the default design at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;forced-colors&lt;/code&gt;: Respect Windows High Contrast Mode
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;forced-colors: active&lt;/code&gt; fires when Windows High Contrast Mode (or the broader Forced Colors spec) is enabled. The OS overrides most of your colors with a restricted system palette — which is intentional. The problem is it can erase things you're using color to communicate: custom focus rings, SVG icon fills, disabled state styling.&lt;/p&gt;

&lt;p&gt;The answer isn't to fight the OS. Let the forced palette do its job, but use the CSS &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/system-color" rel="noopener noreferrer"&gt;system color keywords&lt;/a&gt; to opt specific elements into the forced palette explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forced-colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.icon&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ButtonText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.badge&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;ButtonText&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't restore your brand colors — it hands off to the OS palette, which is exactly what forced-colors users expect. Background images and decorative SVGs will still disappear, and that's fine; they're decorative. What matters is that your UI's meaning survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;None of these require a framework, a library, or a line of JavaScript. They're hooks into decisions users have already made about how they need to experience the web. Start with &lt;code&gt;prefers-reduced-motion&lt;/code&gt; if your site has any animation. Layer in &lt;code&gt;prefers-color-scheme&lt;/code&gt; if you're not responding to OS theme. Add &lt;code&gt;prefers-contrast&lt;/code&gt; and &lt;code&gt;forced-colors&lt;/code&gt; blocks where your UI relies on subtle visual cues.&lt;/p&gt;

&lt;p&gt;Users who need these settings have already done their part. Meeting them there is the smallest lift with the biggest payoff — and it's pure CSS.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/css-user-preference-media-queries" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>a11y</category>
      <category>webapis</category>
    </item>
    <item>
      <title>Vue 3.6 Vapor Mode: Opt Out of the Virtual DOM</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Thu, 11 Jun 2026 21:10:23 +0000</pubDate>
      <link>https://dev.to/grimicorn/vue-36-vapor-mode-opt-out-of-the-virtual-dom-50en</link>
      <guid>https://dev.to/grimicorn/vue-36-vapor-mode-opt-out-of-the-virtual-dom-50en</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://danholloran.me/posts/vue-3-6-vapor-mode-opt-out-of-the-virtual-dom" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The virtual DOM has been Vue's engine room since day one. It's the layer that diffs what changed in your component tree and figures out the minimal DOM updates needed. For most apps, it's fast enough that you never think about it. But "fast enough" has always meant there was overhead you were silently paying — and now Vue is letting you opt out.&lt;/p&gt;

&lt;p&gt;Vue 3.6 ships Vapor Mode as a stable, opt-in compilation strategy. Components that use it skip the virtual DOM entirely. Instead of generating VNodes that get diffed at runtime, the compiler generates direct DOM operations — the same approach that makes SolidJS and Svelte 5 so fast. The benchmarks are striking: up to 97% faster renders in component-heavy scenarios, with bundle sizes 20–50% smaller for Vapor-only components. Vue can now mount 100,000 components in roughly 100ms, which puts it squarely in the same performance tier as SolidJS.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Opting In Works
&lt;/h2&gt;

&lt;p&gt;The upgrade path is intentionally low-friction. You don't switch your whole app at once. Vapor is a per-component choice, and it coexists freely with your existing virtual DOM components in the same tree.&lt;/p&gt;

&lt;p&gt;The simplest way to opt in is a single attribute on your &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt; &lt;span class="na"&gt;vapor&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"count++"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Clicked &lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt; times&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The same &lt;code&gt;ref&lt;/code&gt;, the same template syntax, the same Composition API you already know — just with the &lt;code&gt;vapor&lt;/code&gt; attribute telling the compiler to generate direct DOM instructions instead of a VNode tree. Alternatively, you can name the file &lt;code&gt;MyComponent.vapor.vue&lt;/code&gt; to opt in without touching the script block, which is handy when you want to experiment without modifying source files.&lt;/p&gt;

&lt;p&gt;The compiler transform is where the magic happens. A Vapor component's &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt; compiles to something that creates DOM nodes once and then surgically updates them when reactive state changes — no diffing, no intermediate object allocations. The reactivity system (still Proxy-based under the hood, now enhanced with the new Alien Signals model) drives granular updates directly against the real DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Give Up
&lt;/h2&gt;

&lt;p&gt;Vapor Mode isn't a drop-in replacement for every component. The key constraint is that it only supports Composition API and &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt;. Options API components, &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; without &lt;code&gt;setup&lt;/code&gt;, and class-based components can't use Vapor — they stay on the virtual DOM path.&lt;/p&gt;

&lt;p&gt;There's also one notable async exception: &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;. Component trees that rely on Suspense for async data orchestration need to remain on the VDOM path for now, as Suspense is not yet supported in Vapor. That means if a Vapor component is a direct child of a &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundary, you'll want to hold off.&lt;/p&gt;

&lt;p&gt;For libraries and design system components — things that render a lot and get used everywhere — Vapor is a natural fit. A heavily-used &lt;code&gt;&amp;lt;DataTable&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;VirtualList&amp;gt;&lt;/code&gt;, or &lt;code&gt;&amp;lt;Chart&amp;gt;&lt;/code&gt; component that renders thousands of rows is exactly where eliminating VNode overhead pays off most.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Adoption Strategy
&lt;/h2&gt;

&lt;p&gt;The most sensible approach is to start at the leaves. Identify the components in your app that render most frequently or at the highest volume — usually leaf components like list items, table rows, or icon buttons — and add &lt;code&gt;vapor&lt;/code&gt; to those first. Mixed trees work: a virtual DOM parent component can render Vapor children without any special configuration or wrappers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ParentComponent.vue — standard VDOM component --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ul&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ListItem&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"item in items"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"item.id"&lt;/span&gt; &lt;span class="na"&gt;:item=&lt;/span&gt;&lt;span class="s"&gt;"item"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ListItem.vapor.vue — Vapor component, opted in by filename --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nf"&gt;defineProps&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;item&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{{&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="si"&gt;}}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vue handles the boundary between the two rendering modes automatically. From a developer experience standpoint, both components look and feel identical — you just notice the performance difference in profiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bigger Picture
&lt;/h2&gt;

&lt;p&gt;Vapor Mode is the culmination of a direction Evan You signaled years ago: a Vue that can meet the performance bar of compile-time-first frameworks without asking you to abandon the progressive, component-centric model that made Vue popular. You don't have to learn new primitives, switch to JSX, or reason about fine-grained subscriptions manually. The compiler does the work.&lt;/p&gt;

&lt;p&gt;For apps that are hitting real rendering bottlenecks — dashboards with dense real-time data, large lists, or component trees that re-render heavily under user interaction — Vapor Mode is worth evaluating today. Start with one hot component, measure, and expand from there. The opt-in model means there's no risk to the rest of your app.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This post was originally published on &lt;a href="https://danholloran.me/posts/vue-3-6-vapor-mode-opt-out-of-the-virtual-dom" rel="noopener noreferrer"&gt;danholloran.me&lt;/a&gt;. Follow along there for more frontend and dev content.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
