<?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>CSS text-box-trim: The End of Fudging Vertical Padding</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Fri, 14 Aug 2026 08:05:34 +0000</pubDate>
      <link>https://dev.to/grimicorn/css-text-box-trim-the-end-of-fudging-vertical-padding-4d92</link>
      <guid>https://dev.to/grimicorn/css-text-box-trim-the-end-of-fudging-vertical-padding-4d92</guid>
      <description>&lt;p&gt;You give a button &lt;code&gt;padding: 12px&lt;/code&gt; and it comes out looking bottom-heavy. So you split it: &lt;code&gt;padding: 10px 12px 14px&lt;/code&gt;, squint, nudge the numbers, ship it. Two sprints later design swaps the type family and every one of those hand-tuned values is wrong again, because the new font reserves a different amount of invisible space than the old one did.&lt;/p&gt;

&lt;p&gt;That invisible space has a name, and as of this month you can finally cut it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the extra space comes from
&lt;/h2&gt;

&lt;p&gt;Every font ships with metrics that describe more than the letters you can see. There's room above the capitals for accents and diacritics, and room below the baseline for descenders, and the browser reserves all of it whether or not a single "p" appears in your text. On top of that, &lt;code&gt;line-height&lt;/code&gt; adds leading, which the web splits in half and distributes evenly above and below the content area. Matthias Ott's &lt;a href="https://matthiasott.com/notes/the-thing-with-leading-in-css" rel="noopener noreferrer"&gt;The Thing With Leading In CSS&lt;/a&gt; walks through the typesetting history behind that split, but the practical upshot is simple: a text box is always taller than its text, by an amount that changes per font and per &lt;code&gt;line-height&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Symmetric padding on top of an asymmetric box gives you an asymmetric-looking result. That's the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two properties
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;text-box-trim&lt;/code&gt; says which edges to cut. &lt;code&gt;text-box-edge&lt;/code&gt; says where to cut to.&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="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;text-box-trim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* trim-start | trim-end | trim-both | none */&lt;/span&gt;
  &lt;span class="py"&gt;text-box-edge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* over-edge, then under-edge */&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;cap&lt;/code&gt; trims the over edge down to the top of the capital letters. &lt;code&gt;ex&lt;/code&gt; trims to the x-height instead, which is a nicer optical match for some display faces. &lt;code&gt;alphabetic&lt;/code&gt; trims the under edge flush with the baseline. There's a &lt;code&gt;text&lt;/code&gt; value on both sides if you want the font's own text edges rather than the letterform edges.&lt;/p&gt;

&lt;p&gt;In practice you'll write the shorthand and move on:&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="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;text-box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&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's the version you want roughly all of the time. Now &lt;code&gt;padding: 12px&lt;/code&gt; actually means twelve pixels of visible space on every side, in every font, and swapping the type family doesn't silently re-break your spacing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually pays off
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Optical centering.&lt;/strong&gt; Buttons, badges, pill-shaped tags, anything small and intrinsically sized. These are the components where half-leading is proportionally largest and most obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aligning text next to non-text.&lt;/strong&gt; Put a 40px avatar beside a heading and the heading's box is taller than its letters, so the two never quite line up. Trim the heading and they do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gaps that mean something.&lt;/strong&gt; In a stacked block of text, &lt;code&gt;gap&lt;/code&gt; and &lt;code&gt;margin-block&lt;/code&gt; measure box edges, which include the invisible band on both sides. Trim the leading and your rhythm values start describing the space a reader actually perceives, instead of that space minus an unknown font-dependent constant.&lt;/p&gt;

&lt;p&gt;One caveat worth being deliberate about: trimming removes &lt;em&gt;visual&lt;/em&gt; space, not just visual space you didn't want. If you trim a button and don't add the padding back, you've quietly shrunk the tap target. WCAG 2.2's target size minimum is 24 by 24 CSS pixels, and half-leading was doing invisible work toward that number. Trim, then set the padding you actually meant.&lt;/p&gt;

&lt;p&gt;Multi-line text behaves the way you'd hope: the trim applies above the first formatted line and below the last one, never between lines. And because it's defined in terms of over and under rather than top and bottom, it follows &lt;code&gt;writing-mode&lt;/code&gt; correctly without any extra work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it
&lt;/h2&gt;

&lt;p&gt;Chrome and Edge have had this since 133, Safari since 18.2, and Firefox 154 turns it on by default. That release is dated August 18, 2026, which makes &lt;code&gt;text-box&lt;/code&gt; a Baseline newly-available feature about a week from now.&lt;/p&gt;

&lt;p&gt;Even before that lands everywhere, the failure mode is the kindest one CSS offers: a browser that doesn't recognize the property ignores the declaration and renders the spacing it always did. Nothing collapses, nothing overlaps, you just don't get the tightening. That makes it safe to add today with no fallback at all.&lt;/p&gt;

&lt;p&gt;If the trimmed spacing is load-bearing in a particular component, gate the compensating padding behind a feature query so the two values stay in sync:&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;.tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text-box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;text-box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;trim-both&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="n"&gt;alphabetic&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt; &lt;span class="m"&gt;12px&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 you've ever used Figma's vertical trim control and wondered why the handoff never matched, this is the missing half. Adam Argyle's &lt;a href="https://developer.chrome.com/blog/css-text-box-trim" rel="noopener noreferrer"&gt;Chrome for Developers post&lt;/a&gt; has an interactive playground that's worth ten minutes of poking at with your own type stack, and the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-box-trim" rel="noopener noreferrer"&gt;MDN reference&lt;/a&gt; has the full value grammar. Start with your buttons. The difference is small, and you will not be able to unsee it.&lt;/p&gt;

</description>
      <category>css</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Trusted Types Is Baseline: DOM XSS Is Now a Type Error</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Tue, 11 Aug 2026 08:05:20 +0000</pubDate>
      <link>https://dev.to/grimicorn/trusted-types-is-baseline-dom-xss-is-now-a-type-error-1k1h</link>
      <guid>https://dev.to/grimicorn/trusted-types-is-baseline-dom-xss-is-now-a-type-error-1k1h</guid>
      <description>&lt;p&gt;Every codebase has one. Somewhere in a component nobody has opened in eighteen months, there is a line that reads &lt;code&gt;el.innerHTML = someValue&lt;/code&gt;, and nobody can tell you with confidence where &lt;code&gt;someValue&lt;/code&gt; comes from. Maybe it's a hardcoded template. Maybe it's a server response. Maybe, three refactors ago, it started carrying a slice of &lt;code&gt;location.hash&lt;/code&gt;. That uncertainty is the entire DOM XSS problem: the sink is a plain string setter, strings all look alike, and the browser has no way to tell a trusted one from an attacker-controlled one.&lt;/p&gt;

&lt;p&gt;Trusted Types fixes that by refusing strings outright. And as of February 2026, when Firefox 148 shipped support, it's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API" rel="noopener noreferrer"&gt;Baseline&lt;/a&gt; — Chrome and Edge have had it since 83 back in 2020, Safari joined in version 26, and now the whole core browser set is covered. It's no longer a Chrome-only hardening trick you bolt onto an internal admin tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a whole class of bug into a runtime error
&lt;/h2&gt;

&lt;p&gt;The API works by locking down the risky sinks: &lt;code&gt;innerHTML&lt;/code&gt;, &lt;code&gt;outerHTML&lt;/code&gt;, &lt;code&gt;insertAdjacentHTML&lt;/code&gt;, &lt;code&gt;document.write&lt;/code&gt;, &lt;code&gt;DOMParser.parseFromString&lt;/code&gt;, &lt;code&gt;&amp;lt;iframe srcdoc&amp;gt;&lt;/code&gt;, script &lt;code&gt;src&lt;/code&gt; and text content, and the code-compiling family (&lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;new Function()&lt;/code&gt;, string-argument &lt;code&gt;setTimeout&lt;/code&gt; and &lt;code&gt;setInterval&lt;/code&gt;). Once enforcement is on, passing a raw string to any of them throws a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You opt in with a CSP header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: require-trusted-types-for 'script'; trusted-types escape-html;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first directive is the switch. The second is an allowlist of policy names — factories that are the only things permitted to mint a trusted value:&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;const&lt;/span&gt; &lt;span class="nx"&gt;escapeHTMLPolicy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trustedTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;escape-html&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="na"&gt;createHTML&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&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;amp;lt;&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;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;escapeHTMLPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHTML&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;img src=x onerror=alert(1)&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;TrustedHTML&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fine&lt;/span&gt;

&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userInput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// TypeError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what this actually buys you. Trusted Types does not sanitize anything — your &lt;code&gt;createHTML&lt;/code&gt; function is still your own code and can still be wrong. What it guarantees is that &lt;em&gt;every&lt;/em&gt; path into a dangerous sink now runs through a named policy you declared on purpose. The DOM XSS attack surface of the entire app collapses down to the handful of lines inside your policies. That is a security review you can finish in an afternoon instead of grepping 40,000 lines for &lt;code&gt;innerHTML&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also worth knowing before you start: Trusted Types only works in secure contexts, so HTTPS or &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out without breaking production
&lt;/h2&gt;

&lt;p&gt;Do not flip enforcement on first. Ship the report-only variant, let it run against real traffic, and collect what breaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; report-uri /csp-reports
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Violations arrive with the file, line, column, and a &lt;code&gt;script-sample&lt;/code&gt; snippet of the offending value, which is usually enough to find the culprit immediately. If you'd rather not stand up a collector on day one, a &lt;code&gt;ReportingObserver&lt;/code&gt; gets you the same data in the console:&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;new&lt;/span&gt; &lt;span class="nc"&gt;ReportingObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reports&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;for &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;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;reports&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveDirective&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;require-trusted-types-for&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Trusted Types violation:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then work the list. Most violations have a boring fix — the code didn't need string HTML in the first 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="c1"&gt;// before&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;img src=xyz.jpg&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// after&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceChildren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img&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="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xyz.jpg&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;Where you genuinely need to render untrusted HTML, reach for a sanitizer that already speaks the protocol. DOMPurify will hand back a &lt;code&gt;TrustedHTML&lt;/code&gt; instead of a string if you ask:&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;import&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&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;dompurify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;RETURN_TRUSTED_TYPE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The escape hatch is a policy literally named &lt;code&gt;default&lt;/code&gt;, which the browser applies to any string that reaches a sink without one. It's the right tool when a third-party script from a CDN is the thing violating and you can't patch it. Use it grudgingly — a default policy re-centralizes all your sanitization decisions in one function that has no idea what context it's being called from, which is most of the way back to where you started.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;Trusted Types and the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API" rel="noopener noreferrer"&gt;Sanitizer API&lt;/a&gt; solve adjacent halves of the same problem and pair well: the Sanitizer decides &lt;em&gt;what HTML is safe&lt;/em&gt;, Trusted Types enforces &lt;em&gt;that something made that decision at all&lt;/em&gt;. Neither replaces a strict, nonce-based CSP for the server-rendered side of XSS.&lt;/p&gt;

&lt;p&gt;If you own an app that handles anything sensitive, the report-only header is close to free — one line of config, zero behavior change, and a list of exactly where your DOM XSS risk lives. Start there, and decide later whether to enforce.&lt;/p&gt;

&lt;p&gt;Further reading: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API" rel="noopener noreferrer"&gt;MDN's Trusted Types API reference&lt;/a&gt; and the &lt;a href="https://web.dev/articles/trusted-types" rel="noopener noreferrer"&gt;web.dev deep dive&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webapis</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The Long Animation Frames API: Find What Actually Broke Your INP</title>
      <dc:creator>Danny Holloran</dc:creator>
      <pubDate>Mon, 10 Aug 2026 08:06:37 +0000</pubDate>
      <link>https://dev.to/grimicorn/the-long-animation-frames-api-find-what-actually-broke-your-inp-bep</link>
      <guid>https://dev.to/grimicorn/the-long-animation-frames-api-find-what-actually-broke-your-inp-bep</guid>
      <description>&lt;p&gt;Your real user monitoring dashboard says the 75th percentile INP on your product page is 412ms. You open DevTools, click the same button forty times, and every interaction comes back at 60ms. Nothing reproduces. So you start guessing: maybe it's the analytics tag, maybe it's the third-party chat widget, maybe it's that one &lt;code&gt;useEffect&lt;/code&gt; that everybody is afraid to touch.&lt;/p&gt;

&lt;p&gt;The Long Tasks API was supposed to help here, and it mostly didn't. It would tell you a task ran for 210ms and attribute it to "self" or an iframe container, which is roughly as actionable as a smoke alarm that only reports "somewhere in the house." The Long Animation Frames API is the replacement, and it does the thing you actually wanted all along: it hands you a script URL, a function name, and a character offset.&lt;/p&gt;

&lt;h2&gt;
  
  
  A frame is a better unit of measurement than a task
&lt;/h2&gt;

&lt;p&gt;Slow interactions are rarely one fat task. They're usually five medium tasks, a &lt;code&gt;ResizeObserver&lt;/code&gt; callback, and a style recalculation, all landing between two paints. Long Tasks only saw the fat ones, and it stopped measuring before rendering even started.&lt;/p&gt;

&lt;p&gt;LoAF widens the window. It reports on any animation frame whose total work exceeded 50ms, covering everything from the first task in that frame through style, layout, and paint. Entries arrive with &lt;code&gt;entryType&lt;/code&gt; of &lt;code&gt;long-animation-frame&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&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;PerformanceObserver&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;list&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;for &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;frame&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntries&lt;/span&gt;&lt;span class="p"&gt;())&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="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;blockingDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blockingDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;scriptTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderStart&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;renderStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startTime&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="na"&gt;styleAndLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;styleAndLayoutStart&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paintTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;styleAndLayoutStart&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;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;long-animation-frame&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four timestamps do most of the work. &lt;code&gt;startTime&lt;/code&gt; is when the frame began, &lt;code&gt;renderStart&lt;/code&gt; is when script handed off to the rendering pipeline, &lt;code&gt;styleAndLayoutStart&lt;/code&gt; is where recalc and layout begin, and &lt;code&gt;paintTime&lt;/code&gt; is where it ends. Subtracting them tells you whether you have a JavaScript problem or a layout problem, which are fixed in completely different ways.&lt;/p&gt;

&lt;p&gt;The one field to alert on is &lt;code&gt;blockingDuration&lt;/code&gt;. It sums the over-50ms portion of every long task in the frame plus the rendering time, so it approximates how long the main thread was genuinely unavailable to a user. A 300ms frame with a 20ms &lt;code&gt;blockingDuration&lt;/code&gt; is fine. A 120ms frame with a 90ms &lt;code&gt;blockingDuration&lt;/code&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Script attribution is the whole point
&lt;/h2&gt;

&lt;p&gt;Every LoAF entry carries a &lt;code&gt;scripts&lt;/code&gt; array of &lt;code&gt;PerformanceScriptTiming&lt;/code&gt; objects, and this is where the guessing stops:&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;const&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&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="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="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// https://cdn.example.com/widget.js&lt;/span&gt;
  &lt;span class="na"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceFunctionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// handleScroll&lt;/span&gt;
  &lt;span class="na"&gt;char&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceCharPosition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 18422&lt;/span&gt;
  &lt;span class="na"&gt;invoker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// window.onscroll&lt;/span&gt;
  &lt;span class="na"&gt;invokerType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invokerType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// event-listener&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;forcedLayout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forcedStyleAndLayoutDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;worst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pauseDuration&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;invokerType&lt;/code&gt; tells you &lt;em&gt;how&lt;/em&gt; the script got run: &lt;code&gt;event-listener&lt;/code&gt;, &lt;code&gt;user-callback&lt;/code&gt;, &lt;code&gt;resolve-promise&lt;/code&gt;, &lt;code&gt;module-script&lt;/code&gt;, &lt;code&gt;classic-script&lt;/code&gt;. That distinction matters, because a slow &lt;code&gt;event-listener&lt;/code&gt; is your code to fix and a slow &lt;code&gt;classic-script&lt;/code&gt; during load is usually a tag manager you need to defer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;forcedStyleAndLayoutDuration&lt;/code&gt; is quietly the best field in the API. If it's a meaningful chunk of &lt;code&gt;duration&lt;/code&gt;, you have layout thrashing: something read &lt;code&gt;offsetHeight&lt;/code&gt; or &lt;code&gt;getBoundingClientRect()&lt;/code&gt; inside a write loop and forced synchronous layout. You now know which function, in which file, at which character.&lt;/p&gt;

&lt;p&gt;The caveat worth knowing before you build dashboards on this: attribution only covers same-origin main-thread scripts. Cross-origin iframes, web workers, service workers, and browser extensions can lengthen a frame without ever showing up in &lt;code&gt;scripts&lt;/code&gt;. When you see a big &lt;code&gt;duration&lt;/code&gt; with a nearly empty &lt;code&gt;scripts&lt;/code&gt; array, that absence is itself the signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tie it to the interaction, not just the clock
&lt;/h2&gt;

&lt;p&gt;A stream of slow frames is noise until you connect it to a specific bad interaction. The easiest path is &lt;code&gt;web-vitals&lt;/code&gt; with the attribution build, which already correlates LoAF entries with the INP interaction for 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onINP&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;web-vitals/attribution&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;onINP&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attribution&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;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&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;culprit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longAnimationFrameEntries&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&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="nf"&gt;beacon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/rum&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="na"&gt;inp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interactionTarget&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;processing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processingDuration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;presentation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;attribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;presentationDelay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;culprit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;culprit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceURL&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="nx"&gt;culprit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceFunctionName&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send the single worst script, not the whole entry. A busy page can produce dozens of LoAFs in a session and serializing all of them will cost you more than the insight is worth.&lt;/p&gt;

&lt;p&gt;LoAF shipped in Chrome 123 after an origin trial, and it's still Chromium-only. That sounds limiting until you remember that Chrome is also where your Core Web Vitals field data comes from, so the coverage gap and the metric you're optimizing line up almost exactly. Start by logging &lt;code&gt;blockingDuration&lt;/code&gt; and the top script for interactions over 200ms, then read the &lt;a href="https://developer.chrome.com/docs/web-platform/long-animation-frames" rel="noopener noreferrer"&gt;Chrome documentation&lt;/a&gt; and the &lt;a href="https://w3c.github.io/long-animation-frames/" rel="noopener noreferrer"&gt;spec&lt;/a&gt; once you know which part of your frame is actually on fire.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>webapis</category>
      <category>javascript</category>
      <category>webperf</category>
    </item>
    <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>
  </channel>
</rss>
