<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Starred Articles</title>
    <description>Starred Articles</description>
    <atom:link href="https://feedbin.com/starred/630a51376f6236f1db65212704980f4f.xml" rel="self" type="application/rss+xml"/>
    <link>https://feedbin.com/</link>
    <item>
      <title><![CDATA[Improvements to Web for AI Should Benefit All Users]]></title>
      <description><![CDATA[
<figure class="wp-block-image size-full">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%" aria-hidden="true" focusable="false" role="presentation">
    <!-- Row 1 -->
    <rect x="0" y="0" width="100" height="100" fill="#215cca" class="tile"></rect>
    <text x="50" y="72" text-anchor="middle" class="c4emoji">🧏‍♀️</text>
    <rect x="100" y="0" width="100" height="100" fill="#d9118f" class="tile"></rect>
    <text x="150" y="72" text-anchor="middle" class="c4emoji">👩🏽‍🦼‍➡️</text>
    <rect x="200" y="0" width="100" height="100" fill="#0e1c43" class="tile"></rect>
    <text x="250" y="72" text-anchor="middle" class="c4emoji agent">🤖</text>
    <rect x="300" y="0" width="100" height="100" fill="#f2f5f7" class="tile"></rect>
    <text x="350" y="72" text-anchor="middle" class="c4emoji">👵🏿</text>
    <rect x="400" y="0" width="100" height="100" fill="#f27041" class="tile"></rect>
    <text x="450" y="72" text-anchor="middle" class="c4emoji">🧒🏼</text>
    <rect x="500" y="0" width="100" height="100" fill="#158466" class="tile"></rect>
    <text x="550" y="72" text-anchor="middle" class="c4emoji">👨🏻‍🦯</text>
    <rect x="600" y="0" width="100" height="100" fill="#950cde" class="tile"></rect>
    <text x="650" y="72" text-anchor="middle" class="c4emoji">👩🏾</text>
    <rect x="700" y="0" width="100" height="100" fill="#0e1c43" class="tile"></rect>
    <text x="750" y="72" text-anchor="middle" class="c4emoji agent">🤖</text>

    <!-- Row 2 -->
    <rect x="0" y="100" width="100" height="100" fill="#950cde" class="tile"></rect>
    <text x="50" y="172" text-anchor="middle" class="c4emoji">👨🏽</text>
    <rect x="100" y="100" width="100" height="100" fill="#f27041" class="tile"></rect>
    <text x="150" y="172" text-anchor="middle" class="c4emoji">🧓🏻</text>
    <rect x="200" y="100" width="100" height="100" fill="#158466" class="tile"></rect>
    <text x="250" y="172" text-anchor="middle" class="c4emoji">🧑‍🦽</text>
    <rect x="300" y="100" width="100" height="100" fill="#0e1c43" class="tile"></rect>
    <text x="350" y="172" text-anchor="middle" class="c4emoji agent">🤖</text>
    <rect x="400" y="100" width="100" height="100" fill="#f2f5f7" class="tile"></rect>
    <text x="450" y="172" text-anchor="middle" class="c4emoji">🧑🏼</text>
    <rect x="500" y="100" width="100" height="100" fill="#215cca" class="tile"></rect>
    <text x="550" y="172" text-anchor="middle" class="c4emoji">👩‍🦳</text>
    <rect x="600" y="100" width="100" height="100" fill="#d9118f" class="tile"></rect>
    <text x="650" y="172" text-anchor="middle" class="c4emoji">🧑‍🦲</text>
    <rect x="700" y="100" width="100" height="100" fill="#158466" class="tile"></rect>
    <text x="750" y="172" text-anchor="middle" class="c4emoji">🧏‍♂️</text>

    <!-- Row 3 -->
    <rect x="0" y="200" width="100" height="100" fill="#f2f5f7" class="tile"></rect>
    <text x="50" y="272" text-anchor="middle" class="c4emoji">👩🏾‍🦯‍➡️</text>
    <rect x="100" y="200" width="100" height="100" fill="#215cca" class="tile"></rect>
    <text x="150" y="272" text-anchor="middle" class="c4emoji">👨🏿</text>
    <rect x="200" y="200" width="100" height="100" fill="#d9118f" class="tile"></rect>
    <text x="250" y="272" text-anchor="middle" class="c4emoji">🧓🏽</text>
    <rect x="300" y="200" width="100" height="100" fill="#215cca" class="tile"></rect>
    <text x="350" y="272" text-anchor="middle" class="c4emoji">👩🏻‍🦰</text>
    <rect x="400" y="200" width="100" height="100" fill="#950cde" class="tile"></rect>
    <text x="450" y="272" text-anchor="middle" class="c4emoji">🧑‍🦼</text>
    <rect x="500" y="200" width="100" height="100" fill="#0e1c43" class="tile"></rect>
    <text x="550" y="272" text-anchor="middle" class="c4emoji agent">🤖</text>
    <rect x="600" y="200" width="100" height="100" fill="#158466" class="tile"></rect>
    <text x="650" y="272" text-anchor="middle" class="c4emoji">🧏🏿</text>
    <rect x="700" y="200" width="100" height="100" fill="#f27041" class="tile"></rect>
    <text x="750" y="272" text-anchor="middle" class="c4emoji">👩🏼‍⚕️</text>

    <!-- Row 4 -->
    <rect x="0" y="300" width="100" height="100" fill="#158466" class="tile"></rect>
    <text x="50" y="372" text-anchor="middle" class="c4emoji">👨🏼‍🦽</text>
    <rect x="100" y="300" width="100" height="100" fill="#0e1c43" class="tile"></rect>
    <text x="150" y="372" text-anchor="middle" class="c4emoji agent">🤖</text>
    <rect x="200" y="300" width="100" height="100" fill="#950cde" class="tile"></rect>
    <text x="250" y="372" text-anchor="middle" class="c4emoji">👴🏾</text>
    <rect x="300" y="300" width="100" height="100" fill="#f27041" class="tile"></rect>
    <text x="350" y="372" text-anchor="middle" class="c4emoji">👧🏻</text>
    <rect x="400" y="300" width="100" height="100" fill="#d9118f" class="tile"></rect>
    <text x="450" y="372" text-anchor="middle" class="c4emoji">🥷🏻</text>
    <rect x="500" y="300" width="100" height="100" fill="#215cca" class="tile"></rect>
    <text x="550" y="372" text-anchor="middle" class="c4emoji">🧑🏽‍🦯</text>
    <rect x="600" y="300" width="100" height="100" fill="#f2f5f7" class="tile"></rect>
    <text x="650" y="372" text-anchor="middle" class="c4emoji">👨‍👩‍👦</text>
    <rect x="700" y="300" width="100" height="100" fill="#950cde" class="tile"></rect>
    <text x="750" y="372" text-anchor="middle" class="c4emoji">👩🏾‍🦱</text>
  </svg>
</figure>
<style type="text/css">
 .c4emoji {
    font-family: "Apple Color c4emoji", "Segoe UI c4emoji", "Noto Color c4emoji", "Android c4emoji", sans-serif;
    font-size: 3em;
    transition: opacity .4s ease-in-out;
  }

  .c4emoji:not(.agent) {
    opacity: 0;
  }

  .c4emoji.reveal {
    opacity: 1;
  }
</style>

<script>
  const c4emojis = document.querySelectorAll(".c4emoji:not(.agent)");
  const shuffled = [...c4emojis].sort(() => Math.random() - 0.5);
  setTimeout(() => {
    shuffled.forEach((c4emoji, i) => { setTimeout(() => {   c4emoji.classList.add("reveal"); }, i * 75); // stagger between each c4emoji
    });
  }, 1000); // initial pause before any appear
</script>




<p class="wp-block-paragraph">Last week, the <a href="https://github.com/WebKit/standards-positions/issues/670#issuecomment-4608432694">Safari team formally expressed their opposition to the WebMCP API</a>. In Safari’s explanation of why they oppose WebMCP, they captured something I’ve been trying to articulate.</p>



<p class="wp-block-paragraph">In the comment explaining the Safari team position, Mike Wyrzykowski writes:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">When a site’s actions are hard for an agent to use, that is a gap in the page’s own semantics, and the fix, in our opinion, is to close it in the platform’s shared layers (HTML and ARIA), where the user, assistive technology, and agents all benefit.&nbsp;</p>
</blockquote>



<p class="wp-block-paragraph">Amen. </p>



<p class="wp-block-paragraph">In fact, this sentiment should guide any efforts to change the web to support AI agents better. It’s possible that not every enhancement for AI can be structured in a way that supports all of the web’s shared layers, but we should strive to do so first before reaching for solutions that only benefit AI and not the humans the web is supposed to serve.</p>



<p class="wp-block-paragraph">The Safari teams continues:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Our deeper concern is architectural. An agent acting on a user’s behalf is, in effect,&nbsp;<strong>assistive technology</strong>: it should operate a site as the user would, and the site should not single it out for different treatment.&nbsp;</p>
</blockquote>



<p class="wp-block-paragraph">And on accessibility:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">The proposal says WebMCP “is not designed for ingestion by accessibility technology”, the same fork: richer, actionable semantics reach agents while screen-reader and keyboard users get less.</p>
</blockquote>



<p class="wp-block-paragraph">All of this reminds me of a recent discussion on the Web Incubator Community Group (WICG) mailing list about a <a href="https://ai-domain-data.org/spec/v0.1/" data-type="link" data-id="https://ai-domain-data.org/spec/v0.1/">proposed standard</a> to help AI understand domain authority. The proposed standard is designed to solve the following problem:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">AI assistants often misidentify or misrepresent domains because there is no consistent, machine-readable, domain-controlled source of identity data. Today, models rely on scraped pages, inconsistent metadata, third-party aggregators, or outdated indexes. There is no canonical place where a domain can declare who they are, what they represent, or which resources are authoritative.</p>
</blockquote>



<p class="wp-block-paragraph">The proposed solution suggests adding a JSON-LD document to every website to solve the problem.</p>



<p class="wp-block-paragraph">Humans struggle with domain authority as well. People have trouble telling what websites are legitimate and which aren’t. If we’re solving this problem for AI, perhaps we can find a solution that works for end users too.</p>



<p class="wp-block-paragraph">And let’s set aside for the moment the irony that AI is supposed to replace all of our jobs and become a super intelligence and at the same time we also need to add special AI training wheels for it to use the web.</p>



<p class="wp-block-paragraph">I’d like to see the Safari team’s response to the Web MCP API canonized as a W3C Design Principle. Maybe a slight rewrite like this:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">When a site’s actions are hard for an AI agent to use, that is a gap in the page’s own semantics, and we should first seek to close it in the platform’s shared layers (HTML and ARIA), where the user, assistive technology, and agents all benefit.&nbsp;</p>
</blockquote>



<p class="wp-block-paragraph">We can put it right next to the <a href="https://www.w3.org/TR/design-principles/#priority-of-constituencies">W3C’s Priority of Constituencies</a>:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">If a trade-off needs to be made, always put user needs above all…</p>



<p class="wp-block-paragraph">User needs come before the needs of web page authors, which come before the needs of user agent implementors, which come before the needs of specification writers, which come before theoretical purity.</p>
</blockquote>



<p class="wp-block-paragraph">You’ll notice AI agents aren’t in that priority of constituencies, but assuming agents stick around, it might be worth considering where we’d put them. </p>



<p class="wp-block-paragraph">I hope we can all agree that user needs come before agent needs. We should keep this in mind as we consider proposals to modify the web to support AI.</p>



<p class="wp-block-paragraph"></p>

<hr>
<h2>We’re Cloud Four</h2>
<p>We solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.</p>

<p><a href="https://cloudfour.com/made/"><b>See our work</b></a></p>]]></description>
      <pubDate>Mon, 15 Jun 2026 19:07:36 +0000</pubDate>
      <link>https://cloudfour.com/thinks/improvements-to-web-for-ai-should-benefit-all-users/</link>
      <dc:creator>Sharing what we learn about the responsive web – Cloud Four</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5260922363</guid>
    </item>
    <item>
      <title><![CDATA[Delivering a dynamic hexagonal world map in just 10kb]]></title>
      <description><![CDATA[How we built a new custom hexagonal map displaying user experience rating]]></description>
      <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
      <link>https://calibreapp.com/blog/building-our-beloved-hex-map</link>
      <dc:creator>Calibre Blog</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5200577575</guid>
    </item>
    <item>
      <title><![CDATA[New to the web platform in April]]></title>
      <description><![CDATA[Discover some of the interesting features that have landed in stable and beta web browsers during April 2026.]]></description>
      <pubDate>Fri, 24 Apr 2026 07:00:00 +0000</pubDate>
      <link>https://web.dev/blog/web-platform-04-2026?hl=en</link>
      <dc:creator>web.dev: Blog</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5206893649</guid>
    </item>
    <item>
      <title><![CDATA[Sizing Chaos]]></title>
      <description><![CDATA[The inter-generational struggle to find clothes that fit more than a tiny portion of women.]]></description>
      <pubDate>Wed, 18 Feb 2026 06:00:00 +0000</pubDate>
      <link>https://pudding.cool/2026/02/womens-sizing</link>
      <dc:creator>The Pudding</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5122737639</guid>
    </item>
    <item>
      <title><![CDATA[Eames Pavilion System]]></title>
      <description><![CDATA[<div style="float: left; margin: 0 15px 15px 0; padding: 0; border: 1px solid #000000;"><a href="https://uncrate.com/eames-pavilion-system/" rel="bookmark">





    
    



		<img src="https://uncrate.com/assets_c/2026/04/eames-pavilion-system-12-thumb-960xauto-190012.jpg" class="t webfeedsFeaturedVisual" alt="Eames Pavilion System" title="Eames Pavilion System" width="960">
	</a></div>The prefab kit of parts that finally turns Charles and Ray's most democratic vision into a reality. <br><br>Visit <a href="https://uncrate.com/eames-pavilion-system/">Uncrate</a> for the full post.]]></description>
      <pubDate>Fri, 03 Apr 2026 16:30:00 +0000</pubDate>
      <link>https://uncrate.com/eames-pavilion-system/</link>
      <dc:creator>Uncrate</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5180189902</guid>
    </item>
    <item>
      <title><![CDATA[Enhancing Web Components Safely with Self-Destructing CSS]]></title>
      <description><![CDATA[<p>A month ago, I wrote about a defensive CSS convention I've been calling <a href="https://scottjehl.com/posts/this-css-will-self-destruct/">Self-Destructing CSS</a>. As CSS patterns go these days this one is delightfully low-tech, but in my work I've seen it have an outsized impact in ensuring usable experiences for increasingly-heavy websites when network or device conditions aren't ideal.</p>
<p>This notion of delivering "optimistically" while planning for failure is something <a href="https://www.filamentgroup.com/lab/enhancing-optimistically">I've written about before</a>, but the set-it-and-forget-it nature of this latest stab at it makes it my favorite yet. And funny enough, while it's just another take on <a href="https://alistapart.com/article/testdriven/">careful Progressive Enhancement</a>, this one actually feels more like "graceful degradation" to me! Hmm. I won't dwell on that one.</p>
<p>Anyway, last week, <a href="https://bsky.app/profile/bkardell.com/post/3m5ka4yadck2r">Brian Kardell pointed out to me over on Bluesky</a> that the problem Self-Destructing CSS addresses reminded him of one that commonly occurs with Web Components, when developers hide custom elements until they are "defined" by their JavaScript. That's an anti-pattern I covered in <a href="https://scottjehl.com/learn/webcomponentsdemystified/">my Web Components course</a>, and it's common because it aims to work around a very real issue: often, we don't want our users to see a component until it's "ready."</p>
<p>That (anti)-pattern looks like this:</p>
<pre><code>:not(:defined) {
  visibility: hidden;
}
</code></pre>
<p>Let's break that down. CSS gives us the pseudo-state <code>:defined</code> which will match any custom element once it is <em>defined</em> by JavaScript. For example, if you have an element <code>&lt;my-element&gt;</code> in your HTML, it starts out essentially a <code>div</code> with a more descriptive name. That "~div" could be useful on its own, but typically developers will define its custom behavior using the <code>customElements</code> APIs now built into browsers (effectively turning it into a "web component," if you please). That element definition looks like this:</p>
<pre><code>customElements.define('my-element', class extends HTMLElement {
  /*...lifecycle additions can go here */
});
</code></pre>
<p>Once defined, <code>my-element</code> will be selectable in CSS via <code>:defined</code>, along with all of the other "defined" HTML elements. To be clear, that includes <em>every</em> built-in element defined by the browser, so that <code>:defined</code> selector alone isn't terribly useful on its own, and might do better paired with an element selector instead. That said, if we're trying to style all <em>undefined</em> custom elements to hide them before they're <em>defined</em>, then a selector that matches the opposite of <code>:defined</code> is pretty handy. That's how folks arrived at hiding content with <code>:not(:defined) { visibility: hidden; }</code>.</p>
<p>Unfortunately, while this pattern is clever, it's also risky: if JS fails to run or load in any reasonable amount of time, those elements will remain hidden. I've seen this pattern cause content to be invisible for long periods while popular budget-tier devices struggle to churn through massive JavaScript bundles, even if they're on a fast network.</p>
<p>After a brief delay, hiding content entirely just isn't worth whatever effect you're hoping to achieve.</p>
<h2>Improving <code>:not(:defined)</code> with Self-Destructing CSS</h2>
<p>As <a href="https://github.com/whatwg/html/issues/6231#issuecomment-3534623482">I commented in this HTML standard issue about it</a>, this situation is a great use case for self-destructing CSS.</p>
<p>So moving forward, if you need to hide un-enhanced Custom Elements, I suggest you do not do this:</p>
<pre><code>:not(:defined) {
  visibility: hidden;
}
</code></pre>
<p>And instead, do this!</p>
<pre><code>@keyframes hideBriefly {
  0%, 100% { visibility: hidden; } 
}
:not(:defined) { animation: hideBriefly 2s; }
</code></pre>
<p>Here, just as before, as soon as your element gets defined, the hiding will instantly and automatically unapply. But it will also unapply <em>itself</em> after two seconds no matter what, should the JavaScript take that long to do its thing, or fail to run at all. That last part is critical, because in our medium, we can't expect things to just work like they do on our machine. In fact, they usually don't.</p>
<p>Anyway, that's Self-Destructing CSS for web components. Go play some defense out there on behalf of your users! Oh, and if you want to take this the extra mile, make sure that your initially-delivered HTML is useful/meaningful/functional before the JavaScript loads. Otherwise, you might as well just hide it without a fallback, I guess!</p>
]]></description>
      <pubDate>Mon, 17 Nov 2025 00:00:00 +0000</pubDate>
      <link>https://scottjehl.com/posts/web-component-self-destruct-css/</link>
      <dc:creator>Scott Jehl&#39;s Blog</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5018828180</guid>
    </item>
    <item>
      <title><![CDATA[Squarespace &amp; Web Standards: How We Helped Bring HTML Video &amp; Audio Lazy Loading to Today’s Browsers]]></title>
      <description><![CDATA[]]></description>
      <pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate>
      <link>https://scottjehl.com/posts/squarespace-and-web-standards-how-we-helped-bring-html-video-and-audio-lazy-loading-to-today-s-browsers/</link>
      <dc:creator>Scott Jehl&#39;s Blog</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5175156618</guid>
    </item>
    <item>
      <title><![CDATA[Scroll indicators on tables with background colours using animation-timeline]]></title>
      <description><![CDATA[Imagine a HTML table, with alternating rows with different background colours. The table content overflows the container on the right but on a mobile browser, with no obvious scrollbar, there is no affordance to know the table is overflowing. I want to have a shadow/indicator on the side of the table that is overflowing the […]]]></description>
      <pubDate>Fri, 13 Feb 2026 10:27:49 +0000</pubDate>
      <link>https://benfrain.com/scroll-indicators-on-tables-with-background-colours-with-animation-timeline/</link>
      <dc:creator>Ben Frain</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5117250365</guid>
    </item>
    <item>
      <title><![CDATA[The Web is a Guitar Amp Now (Literally)]]></title>
      <description><![CDATA[
<p>I’ve been working with the web platform for years – and I was pretty blown-away to be able play my guitar through a website, and have it processed like a vintage amp.</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img fetchpriority="high" decoding="async" width="1024" height="376" src="https://actsofvolition.com/wp-content/uploads/2026/03/guitar-amp-1024x376.webp" alt="Diagram showing three steps: 1. a guitar, 2. an audio interface, 3. the Tone3000 website" class="wp-image-18294" srcset="https://actsofvolition.com/wp-content/uploads/2026/03/guitar-amp-1024x376.webp 1024w, https://actsofvolition.com/wp-content/uploads/2026/03/guitar-amp-300x110.webp 300w, https://actsofvolition.com/wp-content/uploads/2026/03/guitar-amp-768x282.webp 768w, https://actsofvolition.com/wp-content/uploads/2026/03/guitar-amp-1536x564.webp 1536w, https://actsofvolition.com/wp-content/uploads/2026/03/guitar-amp-2048x752.webp 2048w" sizes="(max-width: 1024px) 100vw, 1024px"></figure>
</div>


<p>I wrote about it <a href="https://www.silverorange.com/blog/the-web-is-guitar-amp">over at the silverorange blog.</a></p>
]]></description>
      <pubDate>Wed, 11 Mar 2026 16:15:19 +0000</pubDate>
      <link>https://actsofvolition.com/2026/03/the-web-is-a-guitar-amp-now-literally/</link>
      <dc:creator>Starred Articles</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5155220344</guid>
    </item>
    <item>
      <title><![CDATA[blakewatson.com - I used Claude Code and GSD to build the accessibility tool I’ve always wanted]]></title>
      <description><![CDATA[

<p>You know my thoughts on generative tools based on large language models, but this example of personal empowerment is undeniably liberating.</p>

<p><a href="https://adactio.com/links/22420">adactio.com/links/22420</a></p>

            ]]></description>
      <pubDate>Sun, 22 Feb 2026 18:18:05 +0000</pubDate>
      <link>https://blakewatson.com/journal/i-used-claude-code-and-gsd-to-build-the-accessibility-tool-ive-always-wanted/</link>
      <dc:creator>Adactio: Links</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5127292745</guid>
    </item>
    <item>
      <title><![CDATA[Making keyboard navigation effortless]]></title>
      <description><![CDATA[Do you use a keyboard to navigate websites? If so, you've probably run into countless accessibility issues where groups of inputs or widgets are not easy, or even possible to get to with just the keyboard.

A keyboard-accessible website lets you easily reach its various sections and then navigate within them in an intuitive way.

<a href="https://winblogs.thesourcemediaassets.com/sites/33/2026/03/focusgroup-illustration.png"><img class="alignnone size-full wp-image-26165" src="https://winblogs.thesourcemediaassets.com/sites/33/2026/03/focusgroup-illustration.png" alt="An app UI mockup which contains a menubar, various switch controls, radio bottoms, and disclosure elements. An orange overlay highlights a hypothetical keyboard navigation flow across the UI. " width="1200" height="822"></a>

Unfortunately, inaccessible websites are common on the internet. The <a href="https://almanac.httparchive.org/en/2025/accessibility#tabindex">Web Almanac reports</a> that about 50% of websites don't use tabindex, a common technique for making keyboard navigation possible on the web.

The issue is complex, though. Web developers can create keyboard-accessible sites by using the technologies at their disposal today. But, often, these technologies don't make things easy for them. Creating a fully keyboard-accessible site, especially one that has complex widgets such as menus, submenus, toolbars, tabs, and other groups of inputs, isn't free; it requires a lot of work and knowledge.

In fact, if you are a web developer yourself, you have probably been confronted with the following common issues when trying to improve the accessibility of your website:
<ul>
 	<li>Setting an incorrect <code>tabindex</code> value or forgetting to set it at all.</li>
 	<li>Forgetting to call the <code>focus()</code> function to move the focus to the right element.</li>
 	<li>Not preventing the default browser behavior when handling arrow keys.</li>
 	<li>Focusing disabled or hidden inputs by mistake.</li>
 	<li>Implementing a keyboard navigation pattern that doesn't match your users' expectations.</li>
 	<li>Not properly handling all text directions.</li>
 	<li>Not restoring the previously selected element on refocus.</li>
 	<li>And more.</li>
</ul>
Even if you've managed to solve all of these issues, you've still faced the consequences: you either had to write a lot of complicated code or had to use a JavaScript library. In both cases, you now have more code to maintain, and your website loads more code on startup.

This, in turn, impacts the users of your website too. The more code your website needs, the more time it will take for the site to load and become usable.
<h2>Introducing focusgroup</h2>
This keyboard accessibility problem has been on our minds for a long time. We designed an early solution for it in <a href="https://github.com/MicrosoftEdge/MSEdgeExplainers/pull/513">2021</a>, called focusgroup, and we then started collaborating with others on it, as part of the <a href="https://open-ui.org">OpenUI community group</a>, in <a href="https://github.com/openui/open-ui/pull/516">2022</a>.

Taking the problem and an early solution to a broad collaborative forum allowed us to mature the focusgroup idea and go through iterations, taking many perspectives into account. This led to a better, more complete solution for web developers.

Today, we're happy to announce that <a href="https://open-ui.org/components/scoped-focusgroup.explainer/">the focusgroup feature</a> is available for early testing by web developers in Microsoft Edge.

Developers can also test the feature in other Chromium-based browsers. We contributed the implementation to the Chromium open-source project to reach as many web users as possible, and we'll be working with other browser vendors to implement focusgroup for the entire web.

If you're a web developer, focusgroup makes your life much easier:
<ul>
 	<li>You don't need to write any <code>tabindex</code> roving code yourself or import a library for it.</li>
 	<li>Using focusgroup is easy; a single HTML attribute is often the only thing you need to add.</li>
 	<li>The arrow keys are handled for you, across all text directions and skipping over disabled and hidden elements.</li>
 	<li>The keyboard navigation patterns that focusgroup provides are based on guidelines from the <a href="https://www.w3.org/WAI/ARIA/apg/">ARIA Authoring Practices Guide</a>.</li>
 	<li>Focusgroup also works for elements in shadow DOM.</li>
</ul>
More importantly, the impact that focusgroup will have for users who navigate websites with a keyboard is huge:
<ul>
 	<li>Sites will load faster!</li>
 	<li>You will get a more consistent keyboard navigation experience across websites.</li>
 	<li>While tabbing will let you navigate through the main sections of a page, the top, right, bottom, and left arrow keys will work as you'd expect and let you access the various elements or inputs inside of a section.</li>
</ul>
We're excited to have web developers try our early implementation of focusgroup in Microsoft Edge and let us know what they think. With your help, we can make the solution even better and eventually ship it as a stable feature of the web platform.

To learn more about the feature and how to use it, check out <a href="https://developer.chrome.com/blog/focusgroup-rfc">our post on the Chrome for developers blog</a>, and let us know what you think.

Together, let's create a more accessible web.]]></description>
      <pubDate>Thu, 05 Mar 2026 16:39:27 +0000</pubDate>
      <link>https://blogs.windows.com/msedgedev/2026/03/05/making-keyboard-navigation-effortless/</link>
      <dc:creator>Microsoft Edge Blog</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5142496706</guid>
    </item>
    <item>
      <title><![CDATA[Webspace Invaders]]></title>
      <description><![CDATA[
                    
																																	<p>A couple of weeks back, I’m sitting at my desk when a direct message from my frontend friend <a href="https://www.kevinpowell.co/">Kevin Powell</a> pops up. Kevin’s a genuinely kind guy. <a href="https://www.youtube.com/@KevinPowell">He makes CSS videos on YouTube</a> and he’s got this way of explaining things that never makes you feel stupid for not knowing them already. He’s one of those folks who still has faith in the Web.</p>
<p>“<em>Hey, hope you’re doing well!</em> </p>
<p><em>I was going to link to something of yours for an article I’m writing for <a href="https://piccalil.li/">Piccalilli</a>, but I keep getting a “This site can’t be reached” error in Chrome. I tried in Firefox, and it’s saying it can’t establish a secure connection... <a href="https://bell.bz/">Andy</a> just left a comment saying it’s working for him, so it might be a regional thing? I have </em>no<em> idea.</em>”</p>
<p>Wait. My site is down? </p>
<p>I switch into the browser and load the site. It’s fine. I hit refresh. Still fine. I pick up my phone and load the site there. Also fine. I feel a little lift of relief, followed immediately by confusion: If my site is down for Kevin but not for me, something’s definitely not right. Maybe it really <em>is</em> a regional thing?</p>
<p>So, I do what any legitimate web person does when confronted with a problem: I throw technology at it. VPN time! I connect through Canada, because that’s where Kevin is based, and sure enough – my website doesn’t load. And I get the same results checking availability through <a href="https://check-host.net">check-host.net</a> from a bunch of other locations: Brazil, Czechia, Hong Kong, India, Japan, and the Netherlands all fail to load the site, for instance.</p>
<p>After a few emails back and forth with my shared hosting provider <a href="https://all-inkl.com/">all-inkl.com</a> the issue is identified: they’ve indeed installed a filter that blocks entire countries from accessing my site. Without telling me. They just decided on their own that the best solution to whatever was happening was geographic surgery. “Your website is being accessed permanently and en masse. This is presumably a DDoS attack,” they wrote. “We have installed a filter that only allows access from certain countries so that we can protect the server from overload.”</p>
<h2>An Alien Invasion</h2>
<p>A DDoS attack? A botnet trying to hammer my server into submission? Really? I look at the access logs. No, this is something different. This is methodical. Millions (!) of requests over a couple of days from things with User-Agent strings reading GPTBot, OAI-Searchbot, Claude-SearchBot, or Meta-ExternalAgent, plus a whole bunch of IP addresses from Singapore, Shenzhen, and other parts of Asia scanning my articles and notes sections. That’s not a botnet DDoS attack. That’s an LLM bot “attack” by OpenAI, Meta, Anthropic, and many others.</p>
<p>In their hunger for data to train their large language models, companies from all over the world are systematically harvesting every word I’ve ever published, feeding it into their language models to keep them fresh – and the side effect, the collateral damage, is that Kevin in Montreal now can’t read my articles because my hosting provider decided the solution was to block Canada and half the rest of the world.</p>
<p>I sat there staring at those logs for a while. The irony wasn’t lost on me. This is my little corner of the web. My writing. With my weird little style mixer up there in the top right. And now it is simultaneously being strip-mined by AI companies and effectively made inaccessible to actual humans around the world who might want to read it.</p>
<p>This is where we are in 2026. There’s something happening on the Web at the moment that almost feels like watching that old arcade game <a href="https://en.wikipedia.org/wiki/Space_Invaders">Space Invaders</a> play out across our servers. Bots and scrapers marching in formation, attacking our servers wave after wave, systematically requesting page after page, relentlessly filling their data stores while we watch our access logs fill up.</p>
<p>The webspace invaders have arrived.</p>
<p>And the numbers are staggering. About 50 percent of web traffic is now coming from non-humans. Bots, crawlers, and agents are constantly moving across our servers in search for new pieces of content to absorb and while <a href="https://radar.cloudflare.com/year-in-review/2025#per-bot-traffic">search engine crawlers are still the highest traffic bot category</a>, AI/LLM bot traffic is constantly surging, with <a href="https://radar.cloudflare.com/year-in-review/2025#ai-crawler-traffic-by-purpose">AI training being the main crawl purpose</a>. According to Cloudflare’s 2025 Year in Review report, <a href="https://radar.cloudflare.com/year-in-review/2025#ai-traffic-share">AI bots originated 4.2 % of HTML requests</a>. But this number <em>excludes</em> Googlebot, which crawls for both search indexing and AI training, and averaged at 4.5 % of requests. Yes, according to Cloudflare, Google’s crawl volume is still bigger than all other AI crawlers combined. And in some months of 2025, the numbers were even more extreme. Take April 26, 2025, for example: on that day, human traffic only accounted for 34.5 % of HTTP requests worldwide. AI bots came in at 4.1 %. A number that is already high on its own but is being dwarfed by Googlebot with a whopping 11 % of HTTP requests. I repeat: 11 % of HTTP requests worldwide coming from Googlebot alone. The rest of “Non-AI” bots averaged at 50.1 % of requests that day. And this number might still include a fair amount of LLM training bots that hide behind fake User-Agent strings.</p>
<h2>The Open Web Subsidizing Big AI?</h2>
<p>Here’s the thing about running a personal site on modest hosting, the kind of hosting most of us are on: you notice when things change. The big platforms, they can absorb all this additional traffic without blinking. They have CDNs and server farms and maybe even entire teams of people thinking about handling bot traffic.</p>
<p>But me? I’m running a simple setup. A Craft CMS site on shared hosting, some static files here and there, nothing fancy. Just like the majority of people with a personal site or a blog. And suddenly, I’m getting hammered with enough traffic that my hosting provider is implementing emergency measures. And I’m not the only one. Just recently, <a href="https://remysharp.com/">Remy</a> did not only notice unusual spikes of traffic on his blog, but his project <a href="https://jsbin.com/">JS Bin</a> was <a href="https://front-end.social/@rem/115980811360095804">hit so hard by massive spikes in network inbound traffic</a> that <a href="https://github.com/jsbin/jsbin/issues/3583">he was struggling to keep the site online</a>. <a href="https://adactio.com/journal/21831">As Jeremy mentioned</a>, The Wikimedia Foundation <a href="https://diff.wikimedia.org/2025/04/01/how-crawlers-impact-the-operations-of-the-wikimedia-projects/">is also seeing unprecedented amounts of traffic generated by scraper bots</a>. While humans tend to browse with intention, those AI crawlers “binge read” indiscriminately and visit also the less popular pages. 65 % of this resource-consuming traffic they get for Wikipedia is now coming from bots. </p>
<p>And when I asked <a href="https://mastodon.social/@matthiasott/115994736479388596">on Mastodon</a> and <a href="https://bsky.app/profile/matthiasott.com/post/3mds2dm7cdc27">Bluesky</a>, plenty of people chimed in. Turns out I’m <em>really</em> not alone. People are seeing the same pattern on their own sites – often serious enough that they’ve had to take action. There’s even a classic tell at the moment: waves of “users” visiting from Singapore with suspiciously old Chrome User-Agent strings.</p>
<p>There’s a power imbalance at work here that’s hard to ignore. Large “AI” companies, the ones with <a href="https://hbr.org/2025/11/how-generative-ai-is-reshaping-venture-capital">billions in venture capital</a>, send their bots to harvest free content. Not only from big publishers or Wikipedia, but from small, independent websites, too. But we, the people running these sites – often as passion projects, as ways to freely share what we’ve learned, as digital gardens we tend in our spare time – we’re the ones paying for the bandwidth and server resources to handle all those additional requests while those companies profit from the training data they extract. It’s an asymmetric battle: small systems absorbing the demands generated at an entirely different, industrial scale.</p>
<p>These companies might not be plotting evil, but they’re <a href="https://drewdevault.com/2025/03/17/2025-03-17-Stop-externalizing-your-costs-on-me.html">externalizing their costs</a> and aren’t particularly concerned about the collateral damage. Sure, some still provide robots.txt compliance. They’ve <a href="https://support.claude.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler">published documentation about their crawlers</a>, albeit often incomplete. But even compliance and documentation won’t change the fundamental math: when you have dozens – no hundreds, soon thousands of companies (just look at <a href="https://huggingface.co/models">the download counts on Hugging Face</a> for any popular model), each running their own scrapers, each one binge-reading your site not once every two weeks like earlier versions of Googlebot used to do, but <em>several times a day</em>, the aggregate effect can quickly overwhelm a small site. And as long as the training data keeps flowing and the models keep improving, why would they change anything?</p>
<h2>Good Bot, Bad Bot</h2>
<p>Alright, it’s bad, obviously. But things get even messier. In Space Invaders, <a href="https://www.youtube.com/watch?v=MU4psw3ccUI">the aliens followed predictable patterns</a>. You could learn their behavior, anticipate their movements, and develop strategies to play the game longer and score higher. The game we’re playing on the Web now? It’s unpredictable.</p>
<p>Because on the Web, we’re now playing against adaptable opponents. Scrapers from Singapore that hide behind spoofed User-Agent strings, in an attempt to appear as though they are a real browser, instead of identifying themselves honestly. Scrapers that change their IP addresses regularly. Scrapers that obviously <em>don’t</em> give a penny about your cute robots.txt. Scrapers that obviously don’t care about the gentleman’s agreement that the web was built upon.</p>
<p>And we’re just entering the next phase. Agentic AI. Systems that can autonomously explore websites, adapt their scraping strategies based on what defenses they encounter, rotate IP addresses, work around rate limiting. The bots are getting smarter by the minute. More persistent. More capable of treating every attempt to protect our sites as a new puzzle to solve.</p>
<p>This isn’t Space Invaders anymore. Those aliens invading our webspaces are locking on and firing relentlessly, constantly adapting, learning, and getting stronger. And here I am – one human with a keyboard – trying to keep my site accessible to other real humans while it is being harvested and under fire from all directions.</p>
<p>And to cap it all, all of this is happening against a backdrop of genuine geopolitical competition where training data for large language models is being seen as strategic infrastructure. The countries and companies that control the best AI models are said to have real future advantages – economically, militarily, politically. This transforms web scraping from an annoyance into something that’s part of a much larger power struggle. Chinese AI companies might not be that concerned about conventions around robots.txt or polite crawling. But honestly, American AI companies are under pressure to maintain their lead, and when prioritizing growth over everyone else in your path is the business model, they’re perfectly willing to cut corners, too. Everyone’s racing, and the open web looks like free resources up for grabs. The result is that my personal blog – your personal blog – becomes collateral damage in this competition. The scrapers don’t care that you’re not Facebook or the New York Times. Your content is data. Data is valuable. And in this race, the externality of overwhelming small site owners is just the cost of doing business.</p>
<h2>“Just Use Cloudflare”</h2>
<p>So naturally, there’s a corporate solution being offered. Cloudflare, with its massive network infrastructure, <a href="https://blog.cloudflare.com/declaring-your-aindependence-block-ai-bots-scrapers-and-crawlers-with-a-single-click/">provides (free) AI bot protection now</a>. You add some DNS entries, flip a switch in the Cloudflare dashboard, and let their systems handle the AI crawler traffic for you.</p>
<p>And from what I hear, it works fairly well. Cloudflare can identify and block malicious scrapers at a scale that individual site owners simply cannot. They can distinguish between real browsers and bots masquerading as browsers. They have the data, the machine learning models, the infrastructure, and the threat intelligence.</p>
<p>But I keep coming back to this question: should we all need to route our traffic through Cloudflare? Is that really the sustainable solution to the webspace invaders problem? Is that really the open, independent web we want?</p>
<p>Because here’s the thing: Cloudflare already sees an enormous percentage of web traffic. They’re a neutral actor now. But power structures change. Companies change. Countries and their governments change. Obviously. Every time we centralize infrastructure in response to a threat, we make the web a little less distributed, a little less resilient, and a little less ours. And we all saw last year what happens when half of the web relies on just one single point of failure. Within just one month, Cloudflare experienced two major outages: a global outage on <a href="https://blog.cloudflare.com/18-november-2025-outage/">November 18, 2025</a> took down roughly one in five webpages globally at the height of the incident, and one-third of the world’s 10,000 most popular websites, apps, and services, according to some estimates. Another significant incident occured on <a href="https://blog.cloudflare.com/5-december-2025-outage/">December 5</a> while Cloudflare attempted to detect and mitigate <a href="https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components">an industry-wide vulnerability in React Server Components</a>.</p>
<p>I sat with all this for a while, trying to figure out how I felt about it and what to do with it. On one hand, I don’t have the time or expertise to run sophisticated bot detection on my own. I have a day job. I have a life and a family. The idea of spending weekends maintaining IP blocklists and analyzing traffic patterns is exhausting just to think about.</p>
<p>On the other hand, every small site owner routing through Cloudflare means more consolidation, more centralization, more of the web’s infrastructure dependent on a single company’s good intentions and continued solvency.</p>
<p>I don’t have a good answer here. I really don’t. It’s one of those problems where all the solutions feel inadequate, somehow. </p>
<p>Still, I decided to give it one last try. And so, I spent the last few weeks moving my site from my shared hosting plan over to a virtual private server (VPS) where I have more control over the protection layers of my site and where nobody can add a country filter without my knowledge. I’ll write more about other aspects of the setup in future posts.</p>
<h2>Activating Your Site Shields</h2>
<p>But for now, let’s look at a few practical ways to fight back against those webspace invaders. Because although the bots and agents are getting smarter by the minute, there is still a lot we can do to better protect our sites and hosting bills.</p>
<h3>You, Robot?</h3>
<p>The first step (still) is to <strong>start with <a href="https://en.wikipedia.org/wiki/Robots.txt">robots.txt</a></strong>, even though this won’t stop them all. Create a robots.txt file in your web root if you don’t have one yet, and then explicitly disallow known AI scrapers of your choice:</p>

																												
																																						
																																	<p>If you want to go further, you can block wide whole range of known LLM bots with a list like the one at <a href="https://github.com/ai-robots-txt/ai.robots.txt">ai.robots.txt</a> or even serve a robots.txt that continuously updates via <a href="https://knownagents.com/docs/robots-txt">Known Agents</a> (formerly known as Dark Visitors – interesting name change, by the way). It’s not a complete solution, but it’s a starting point that takes a few minutes to set up.</p>
<p>However, using robots.txt will only work for the actors that actually honour this convention. If a crawler decides to ignore your robots.txt, nothing will stop it. That’s why a lot of people, <a href="https://ethanmarcotte.com/wrote/blockin-bots/">just like Ethan</a>, have also started blocking bots via <code>.htaccess</code>.</p>
<p>For my new VPS setup, I now use <a href="https://nginx.org/">nginx</a> to serve my site. And so, instead of using <code>.htaccess</code>, which does only exist on Apache, I added a user agent check to the <a href="https://nginx.org/en/docs/beginners_guide.html#conf_structure">nginx config</a> instead, similar to <a href="https://github.com/ai-robots-txt/ai.robots.txt/blob/main/nginx-block-ai-bots.conf">the one you can find in the ai.robots.txt repo</a>:</p>

																												
																																						
																																	<p>If the User-Agent string matches that of one of the bots on the list, the server now returns a <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/403">403 Forbidden</a> status code and blocks the request. In case you're running an Eleventy blog on nginx, <a href="https://rknight.me/blog/blocking-bots-with-nginx/">Robb has written about a similar approach</a> he is using to block bots on his site.</p>
<p>One important note, though: if you want to still allow the Internet Archive to archive your site, which you should, make sure to not block their <code>archive-org_bot</code> – like I did at first. ;)</p>
<h3>Rate Limiting</h3>
<p>If you control your server configuration, rate limiting also becomes essential. Real humans very rarely request fifty pages per second. But aggressive scrapers definitely do. Therefore, putting a cap on how often someone can repeat an action within a certain timeframe can help stop malicious bot activity and reduce strain on your server. If you’re on nginx, look into <code>limit_req_zone</code> and <code>limit_req</code>, as explained in <a href="https://blog.nginx.org/blog/rate-limiting-nginx">this post on the nginx blog</a>. Apache has <a href="https://httpd.apache.org/docs/current/mod/mod_ratelimit.html">mod_ratelimit</a>.</p>
<p>The tricky part is setting the right thresholds. Too strict and you risk blocking legitimate users or search engines. Too loose and the scrapers slip through easily. I’ve been experimenting with this, and honestly, it’s been a lot of trial and error and I’m still not 100 % sure if my settings are ideal. The general advice is to conservative – maybe 5 to 10 requests per second per IP – and adjust based on what you see in your logs.</p>
<p>My own setup, which complements the nginx config setup above, now distinguishes between three tiers of visitors based on User-Agent strings: the most aggressive bots and crawlers are blocked immediately, less aggressive crawlers – those that respect robots.txt – and user-initiated AI searches are moderately throttled, while everyone else (presumably humans, I hope, I’m still very hopeful, I guess) falls into the least restrictive tier. Using IP addresses would strengthen this approach even further, since, as we know, malicious actors will spoof their User-Agent strings. But for now, this additional rate limiting layer should help catch another significant number of aggressive invaders and cut off the peaks.</p>
<h3>Monitoring Your Access Logs</h3>
<p>I know, it sounds tedious. And it is. But you need to know what’s actually hitting your site. And means looking into the access logs of your server or webspace, not just your analytics dashboard. Because tools like Fathom or Plausible will only tell you half of the story. They apply their own filters to keep your analytics clean and bot-free, which is great for understanding human visitors. But that also means that there is no way around looking at your raw logs. </p>
<p>I recently spent hours going through mine, identifying the IPs with the most traffic and blocking them manually as a first, probably a bit unsustainable counter-measure. But it helped me to understand the problem better – and also to check whether my actions actually had an effect.</p>
<p>Tools like <a href="https://goaccess.io/">GoAccess</a> can be super helpful for this, because they give you real-time insights and even let you set up alerts for unusual traffic spikes. Because ideally, you want to know when something weird is happening, not discover it three days later when your hosting provider has already implemented their own “solution.” This goes double for client sites.</p>
<h3>Using IP Blocklists</h3>
<p>IP blocklists are part of any solid firewall setup, but you need to be thoughtful about it. There are well‑established resources that publish lists of IP addresses of known scraping networks, exploiters, spam sources, and other malicious actors – data centers that legitimate traffic would never come from. Useful resources include <a href="https://www.abuseipdb.com/">AbuseIPDB</a>, <a href="https://www.spamhaus.org/blocklists/do-not-route-or-peer/">Spamhaus’s Don’t Route Or Peer Lists (DROP)</a>, <a href="https://www.blocklist.de/en/index.html">blocklist.de</a>, or <a href="https://iplists.firehol.org/">All Cybercrime IP Feeds by FireHOL</a>. </p>
<p>You can then block those IPs with a firewall like Linux’s <a href="https://wiki.archlinux.org/title/Iptables">iptables</a> via <a href="https://wiki.archlinux.org/title/Ipset">ipset</a>. This, however, requires ongoing maintenance because those lists need updating. There are ways to configure your server so that the lists get updated automatically. In my case, I am now using <a href="https://hestiacp.com">Hestia Control Panel</a> on my server, which makes configuring iptables a bit more comfortable when blocking individual IPs or whole blacklists. But you should still be very careful to not block legitimate traffic or even yourself. It is all about finding the most trustworthy lists and not being overly aggressive. And again, watching your logs.</p>
<h3>Poisoning Well</h3>
<p>For the really aggressive invaders, especially if you’re concerned about LLM scrapers harvesting your content without consent, effectively violating your copyright, there’s another approach: <a href="https://heydonworks.com/article/poisoning-well/">serve different content to suspected bots</a>, or as Heydon put it, poison the well, actually. Feed them garbage data. It won’t stop the bandwidth drain, but it does something about the copyright violation.</p>
<h3>WAFs: Blocking the Baddest Baddies</h3>
<p>The final layer of protection would be to install a web application firewall (WAF) that includes bot protection. Open-source tools like <a href="https://www.bunkerweb.io">BunkerWeb</a>, <a href="https://safepoint.cloud/landing/safeline">SafeLine</a>, or the really interesting <a href="https://anubis.techaro.lol">Anubis</a> provide multi-layered defense against bot attacks through CAPTCHA verification or other challenges, dynamic protection, and anti-replay protection. They are basically an additional layer (a reverse-proxy) between your app or website and the internet that only lets the good traffic through.</p>
<p>But the bigger you make the concrete walls around your digital garden, the more likely it might also have negative consequences. You might block legitimate crawlers. You might hurt your accessibility and block real humans. And you might spend hours installing and maintaining systems that break in weird ways. To me personally, tools that put a challenge in front of real humans still feel a bit like admitting defeat.</p>
<p>So I haven’t yet tried Anubis, for example – although it might well be the best way to really get rid of the crawlers. But I’ll definitely keep exploring this space and try out this and other tools, now that I have my own virtual private personal website server.</p>
<h2>The Web We Wanted</h2>
<p>We built the web on optimistic assumptions. We assumed good faith. We assumed people would respect robots.txt because we all understood we were building something great together. We created these protocols and conventions because we believed in mutual respect and shared purpose. The Web was supposed to be for everyone.</p>
<p>But when training data becomes worth billions of dollars, when AI capabilities determine who wins and loses in global economic competition, when scraping is a strategic commercial activity, then those assumptions break down.</p>
<p>Part of me wants to believe that we can still maintain an open web without surrendering to corporate infrastructure or turning every website into a defensive fortress shooting webspace invaders. Part of me wants to believe that international cooperation could establish better norms over time and that the companies building these AI systems will take responsibility for the externalities they’re creating. (Haha.)</p>
<p>But I’m now also old enough to be an optimistic realist. I’m watching my access logs, I’m watching the arms race escalate, and I’m wondering how long places like mine can hold out. How long before every personal website needs enterprise-level protection just to function? How long before the cost of publishing your thoughts online becomes prohibitive for regular people? For some, it might be already. </p>
<p>It feels like we might soon be forced to choose between accessibility and survival. Between openness and sustainability. Between our ideals about what the web should be and the practical reality of keeping our sites online.</p>
<h2>An Infinite Game</h2>
<p>Stories like mine are now probably happening to more small site owners everywhere. Many of them might not even know why their hosting bills are climbing or why their sites are suddenly down in certain countries. And once they find out, many of them might not be able or willing to set up a VPS and spend hours fiddling with server logs, firewalls, blocklists, and nginx config files.</p>
<p>Yes, the AI companies need to do better. They actually should throttle their scraping to reasonable levels. They actually should respect the limited resources of small sites. They actually should develop industry standards that don’t externalize costs onto individuals who are just trying to share their work. This might even be in their best interest, because ultimately, they also risk destroying the very ecosystem they depend on – after all, what will their LLMs learn from when the independent web has been scraped into extinction? But I’m also not holding my breath for voluntary restraint when there are billions of dollars at stake. In the end, it is also on us to adapt to this new reality – clear-eyed and pragmatic.</p>
<p>So what do we do?  We document what’s happening. We block scrapers. We move to better, more capable servers. We share our approaches and learn from each other. We push for better standards and regulations where we can. We make noise about the problem instead of suffering silently. Because this is the Open Web and and the Web was designed so that we <em>can</em> still do all that. That’s the magic of it.</p>
<p>I for one am not giving up my little corner on the Web, of course. I'll continue to document and write about that journey that is my personal website. Over the coming weeks, I will share my experiences with my new VPS setup – how I configured the server, how I set up my continuous deployment pipeline for Craft CMS (yes, I have that now, Manuel! 😉), how I configured caching and other performance improvements, and I’ll also finally write more about the redesign and CSS of my site. Because that’s what our little corners on the Web are all about. That’s what <a href="https://matthiasott.com/articles/into-the-personal-website-verse">the personal website verse</a> is all about. And that’s what we are trying to protect here.</p>
<p>So, if you’re running a website, check your logs. Set up some basic protections. Share what you find. Be part of this conversation. <a href="https://daverupert.com/2026/02/futurescapes/">Write about the future you want</a>. Because the alternative is watching the independent web getting scraped into oblivion while we all wonder what happened to the Web we loved.</p>
<p>Here’s the thing about Space Invaders, the game: it didn’t end when the aliens reached the bottom. It just started over, harder and faster. Maybe that’s where we are now. It’s not game over. Not yet. Just the next level. And we need to get better at playing it.</p>
<p>👾👾👾</p>
<p><em>What has your experience been with AI scrapers? Have you found approaches that work? Have you “given up” or moved to Cloudflare? I’m genuinely curious what’s happening in your corner of the Web, because I suspect we’re all dealing with variations of the same problem. So let me know via Webmention, <a href="https://mastodon.social/@matthiasott">Mastodon</a>, <a href="https://bsky.app/profile/matthiasott.com">Bluesky</a>, <a href="mailto:mail@matthiasott.com">email</a>, or maybe even in a response blog post.</em></p>

																												
																																						            	]]></description>
      <pubDate>Mon, 09 Feb 2026 00:00:00 +0000</pubDate>
      <link>https://matthiasott.com/articles/webspace-invaders</link>
      <dc:creator>Matthias Ott</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5111795579</guid>
    </item>
    <item>
      <title><![CDATA[Fresh Hot CSS: Trig Functions]]></title>
      <description><![CDATA[<p>Today’s topic is Trig Functions. I feel like Trig Functions don’t get a lot of love because nobody seems to know what
you would actually use them for. I don’t get to go over it in the talk very much. Let’s actually show a
demo of where you might want to use something like this.</p>

<h3>Trig Functions</h3>
<p>The Trig functions that are now available are as follows:</p>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/sin"><code>sin()</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/cos"><code>cos()</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/tan"><code>tan()</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/asin"><code>asin()</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/acos"><code>acos()</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/atan"><code>atan()</code></a></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/atan2"><code>atan2()</code></a></li>
</ul>
<p>If you have ever studied Trigonometry, then these do exactly what you think they do. If you didn’t, this is all
extremely helpful for calculating angles and lengths of sides of triangles. Math stuff.</p>
<p>Let’s talk about where you’d use them.</p>
<h3>Placing things in a circle</h3>
<p>This is the classic example that shows off being able to calculate the placement of multiple items based on their angle relative to each other.</p>

      See the Pen &lt;a href="https://codepen.io/bramus/pen/YzBEXJy"&gt;
  CSS Wrapped 2023: Trig Functions&lt;/a&gt; by Bramus (&lt;a href="https://codepen.io/bramus"&gt;@bramus&lt;/a&gt;)
  on &lt;a href="https://codepen.io"&gt;CodePen&lt;/a&gt;.
      
<h3>CLOCKS!!</h3>
<p>You can now easily make a clock that will have hands that are correct</p>

      See the Pen &lt;a href="https://codepen.io/stoumann/pen/wvxOQKo"&gt;
  CSS sin() and cos() Demo 4&lt;/a&gt; by Mads Stoumann (&lt;a href="https://codepen.io/stoumann"&gt;@stoumann&lt;/a&gt;)
  on &lt;a href="https://codepen.io"&gt;CodePen&lt;/a&gt;.
      
<h3>Browser Support</h3>
<p>These functions are well supported, and can absolutely be used, today!</p>
<p></p>
<h3>More Resources</h3>
<ul>
<li><a href="https://web.dev/articles/css-trig-functions">Web.dev</a></li>
</ul>]]></description>
      <pubDate>Thu, 05 Feb 2026 05:30:25 +0000</pubDate>
      <link>https://alex.party/posts/2026-02-05-fresh-hot-css-trig-functions/</link>
      <dc:creator>Alex.Party</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5113111810</guid>
    </item>
    <item>
      <title><![CDATA[Good Tidings!]]></title>
      <description><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*1J1kjGVkpvx-ADVo4ug07g.png"></figure><p>I heard from quite a few people who were excited about the Web Component Engineering course but missed the Thanksgiving sale window. So, as we wrap up the year and head into a new one, I’m running <strong>one more sale</strong> as a bit of year-end / new-year “good tidings.”</p><p>From now through the first week of the new year, you can get <strong>25% off</strong> <a href="https://bluespire.com/course/web-component-engineering">my Web Component Engineering course</a> when you use the discount code <strong>GOODTIDINGS25</strong> at checkout.</p><h3>About the&nbsp;Course</h3><p><a href="https://bluespire.com/course/web-component-engineering/">Web Component Engineering</a> is a self-paced, in-depth course on modern UI engineering through the lens of Web Components and core Web Standards. It’s designed to help you move beyond framework-only knowledge and really understand the Web Platform you’re building&nbsp;on.</p><p>You’ll get a deep dive into topics&nbsp;like:</p><ul><li>Using and authoring Web Components in real applications</li><li>Working directly with DOM APIs instead of only through frameworks and abstractions</li><li>Modern CSS for robust, scalable UI systems (including Shadow DOM styling, container queries, and&nbsp;more)</li><li>Accessibility as a first-class concern in component design</li><li>Form-associated custom elements and integrating components with real&nbsp;forms</li><li>Design systems, tokens, and component libraries built on Web Components</li><li>Application architecture, routing, and app shells with Web Components</li><li>Tools and libraries like Storybook, Playwright, Lit, FAST, and others you’ll use in production</li></ul><p>The course is trusted by engineers at top companies and is designed to be the “missing manual” for serious UI engineers who want to master the modern Web Platform.</p><h3>What You&nbsp;Get</h3><p>When you enroll, you get access&nbsp;to:</p><ul><li>13 modules with over 170 lessons, fully self-paced</li><li>A custom interactive learning app that lets you follow along, run demos, and experiment in the browser with no&nbsp;setup</li><li>Runnable examples, downloadable notes, and reference materials</li><li>A Certificate of Completion you can share with your team or&nbsp;manager</li></ul><p>Whether you’re building a design system, maintaining a complex front-end, or just want to understand what’s really possible with modern Web Standards, this is designed to meet you where you are and push you to the next&nbsp;level.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4iAAqIV2CXYGu6gpWP0TxA.png"><figcaption>The Interactive Learning Experience</figcaption></figure><h3>Sale Details</h3><ul><li><strong>Discount:</strong> 25%&nbsp;off</li><li><strong>Code:</strong> <strong>GOODTIDINGS25</strong></li><li><strong>Valid through:</strong> The end of the first week of the new&nbsp;year</li><li><strong>Course:</strong> <a href="https://bluespire.com/course/web-component-engineering/?utm_source=chatgpt.com">https://bluespire.com/course/web-component-engineering/</a></li></ul><p>If you missed the Thanksgiving window, or you’ve been on the fence, this is another chance to invest in your skills and deepen your understanding of the Web Platform.</p><p>Thanks for being part of the Web community, and here’s to a great year of building better UIs with Web Standards.</p><img src="https://medium.com/_/stat?event=post.clientViewed&amp;referrerSource=full_rss&amp;postId=6f435c8162f3" width="1" height="1" alt="">]]></description>
      <pubDate>Mon, 08 Dec 2025 14:16:30 +0000</pubDate>
      <link>https://eisenbergeffect.medium.com/good-tidings-6f435c8162f3?source=rss-257e6cfa66b3------2</link>
      <dc:creator>Stories by EisenbergEffect on Medium</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5041388967</guid>
    </item>
    <item>
      <title><![CDATA[The Year Of The Linux Desktop (for fitness games)]]></title>
      <description><![CDATA[<h2>A tale told in memes</h2>
<p>I'm <a href="https://xeiaso.net/notes/2026/year-linux-desktop/">joining the bandwagon</a> and declaring that 2026 will be the Year of the Linux Desktop. Specifically for the subset of fitness gaming apps, it's no longer necessary (or preferred) to be tethered to Windows.</p>
<p>Two long-term trends have led to this moment.</p>
<p>Windows has been on a "slowly, then all at once" descent in quality that <a href="https://www.windowscentral.com/microsoft/windows-11/2025-has-been-an-awful-year-for-windows-11-with-infuriating-bugs-and-constant-unwanted-features">really started ramping up last year</a>.</p>
<p><a href="https://surfin.dog/@finn/115102306400374113"><img src="https://www.steele.blue/img/UG9RBhfwWz-600.png" alt="Mastodon post from user finn: &quot;very funny that linux didn't have to get better to gain desktop market share, the alternatives just had to get worse&quot;" decoding="async" loading="lazy" width="940" height="309" srcset="https://www.steele.blue/img/UG9RBhfwWz-600.png 600w, https://www.steele.blue/img/UG9RBhfwWz-940.png 940w" sizes="100vw"></a></p>
<p>Working with our client sysadmins on a daily basis, I get to see first-hand the OOB patches and hotfixes we've been scrambling to apply (including this weekend when Patch Tuesday <a href="https://www.thurrott.com/windows/331777/emergency-windows-11-updates-are-out-to-address-shutdown-and-remote-connection-issues">broke core RDP functionality</a>) and pray don't make the experience worse. Combined with a "Continuous Innovation" and "Controlled Feature Rollout" strategy that launches features when you least expect it, and it's made everything feel ephemeral and ready to break at any moment. When was the last time you got excited for an update, rather than dread it?</p>
<p>I don't think it's any surprise this corresponds with Microsoft's headfirst embrace of generative AI. Both internally (Satya claims 30% of Microsoft's code is AI-written) and externally (the "everything is Copilot" meme, the rebranding of Edge as an agentic browser), it's inescapable.</p>
<p><img src="https://www.steele.blue/img/ld0-K6xbrO-600.jpeg" alt="&quot;My body is a machine&quot; meme making fun of Microsoft" decoding="async" loading="lazy" width="828" height="899" srcset="https://www.steele.blue/img/ld0-K6xbrO-600.jpeg 600w, https://www.steele.blue/img/ld0-K6xbrO-828.jpeg 828w" sizes="100vw"></p>
<p>At the same time, desktop Linux has been steadily improving and growing more reliable. Some of this has to be spillover from investment in downstream OSes like ChromeOS, Android, and Steam OS, all based to some degree or other on Linux. Core peripherals like Bluetooth work across all my devices I've tried it on, on machines both <a href="https://frame.work/linux">designed to run Linux</a> and on PCs with random hardware I didn't even check compatibility with before installing.</p>
<h2>My Distro</h2>
<p>One meme I've struggled with is the tendancy for Linux desktops to start off strong, but degrade in reliability over time.</p>
<p>Historically this feels like a problem I'm also culpable for. A common experience is for userland changes applied to make a Linux desktop functional would inevitably break some unrelated part of the system. Install a new Python 3 runtime for a project, and somehow you've broken your virtual desktop setup, because it relied on 2.7.</p>
<p><a href="https://mastodon.social/@jk/113877803289325585"><img src="https://www.steele.blue/img/TPOsydsXH_-600.png" alt="Social media post from Mastodon user jk: &quot;a big reason i use linux is because it gives me Control over the computer. no windows dark pattern spying shit. i can set up everything to look and function exactly how i want. but every couple months there's a package update that overrides or changes something so it no longer works how i configured it. they just reached in and broke something i worked hard on. and i have no choice in this, because you need to eventually update packages to install new ones. i don't really feel in Control at all&quot;" decoding="async" loading="lazy" width="629" height="281" srcset="https://www.steele.blue/img/TPOsydsXH_-600.png 600w, https://www.steele.blue/img/TPOsydsXH_-629.png 629w" sizes="100vw"></a></p>
<p>The critical drive lately has been the rise of atomic desktops. These distros are "immutable", in that core filesystems are read-only and cannot be modified, even as root. The OS (and supporting packages) are updated all-at-once, and can be rolled back to a previous release if anything goes wrong.</p>
<p>Almost all userland work is accomplished with high amounts of isolation. GUI apps are Flatpak, and CLIs use Homebrew, so they don't interfere with system packages. Want to setup a development environment? On Bluefin, you're pushed to use devcontainers, so everything happens inside an OCI (here Podman) container.</p>
<p>It's pretty incredible how far you can take this approach. Bazzite (a gaming-focused distro) works so well that it's very common to as a drop-in replacement on Steam Deck (replacing an Arch-based OS). I'm running it on my gaming desktop and spent more time fighting Windows UEFI bugs than I did getting a fully-working setup.</p>
<p>Bazzite ships with Steam and its Proton compatibility layer, but my games need to run outside Steam. How does that behave? Turns out, great!</p>
<h2>Zwift</h2>
<p>My favorite <a href="https://www.steele.blue/zwift-greenscreen/">cycling-themed MMORPG</a> doesn't have a native Linux port and probably never will. It also has quite a few Windows system dependencies (its launcher relies on an Edge-based WebView2), and of course it needs access to a GPU and peripherals like Bluetooth/ANT+. I've been trying to get Zwift running for years with tools like Wine/Lutris, but every attempt at getting it running failed, and I'd inevitably corrupt my environment installing incompatible, conflicting versions of Visual C++ Redistributables.</p>
<p>This has been almost completely fixed with Kim Eik's <a href="https://github.com/netbrain/zwift">netbrain/zwift repo</a> - an all-in-one Docker container that setups an isolated Wine environment, installs Zwift, and applies a known set of working patches. The result is a setup that's easier to run than Windows.</p>
<p><img src="https://www.steele.blue/img/dKbdrGnXIY-600.png" alt="Screenshot of Zwift running on a Bazzite Linux desktop" decoding="async" loading="lazy" width="1920" height="1080" srcset="https://www.steele.blue/img/dKbdrGnXIY-600.png 600w, https://www.steele.blue/img/dKbdrGnXIY-1000.png 1000w, https://www.steele.blue/img/dKbdrGnXIY-1920.png 1920w" sizes="100vw"></p>
<p>Not everything works yet. In particular, Bluetooth (needed to interact with the trainer, heart rate monitor, etc) isn't available, though <a href="https://github.com/netbrain/zwift/issues/188">there's ongoing development</a> that I'm hopeful for.</p>
<p>In the meantime, I'm setting things up using alternate means. My trainer <a href="https://support.wahoofitness.com/hc/en-us/articles/9211851310738-Using-Wi-Fi-with-a-KICKR-trainer-BIKE-or-RUN">supports Wi-Fi connections</a>, so no Bluetooth is needed for power, cadence, and resistance. I even set up the trainer on an isolated VLAN (as it's essentially an expensive IoT device I don't have full control over), and Podman was able to discover it after setting <code>networking=host</code>.</p>
<p>Other peripherals (controllers, HR monitors) still require Bluetooth, so I've just got an old Android device running <a href="https://www.zwift.com/companion">Zwift Companion</a> sitting on my trainer desk. Honestly this is the most "non-native" part of my entire setup, but I'm still using supported tools (and was having to use the Companion even on Windows after an update broke BLE connections).</p>
<p>Zwift runs at a clean 60fps, and I'm able to <a href="https://stream.steele.blue/">stream to Owncast</a> via OBS just like before.</p>
<h2>Beat Saber</h2>
<p>This one I thought would be more challenging. As a VR rhythm game, I'd expect significant hurdles to a clean, performant experience.</p>
<p>My setup has always been a little unorthodox: on Windows I've been running a modded Beat Saber, installed using <a href="https://www.bsmanager.io/">BSManager</a> to setup extra songs/mods, and control which version I run. The game then streams to a Quest 2 headset, and I pray the latency gods are favorable. I spent the better part of last year getting this setup working well on Windows, and settled on tweaking Virtual Desktop to stream the game, limiting the resolution and frame rate along the way. Other tools like Oculus Link and Steam's own wireless streaming just weren't cutting it.</p>
<p><lite-youtube videoid="l6kEJ94mC3I"></lite-youtube></p>
<p>Turns out, Linux VR gaming is pretty solid these days. The <a href="https://lvra.gitlab.io/">LVRA community</a> has been compiling tools and best practices for years, and many things are turnkey now. Need to stream to a Quest headset? Pick between <a href="https://lvra.gitlab.io/docs/steamvr/alvr/">ALVR</a> and <a href="https://lvra.gitlab.io/docs/fossvr/wivrn/">WiVRn</a>. And again, both are installed as Flatpaks, so if you get it wrong, it's super easy to change course!</p>
<p>I went with WiVRn, as it bundles its own, Steam-independent OpenXR runtime, which has been supported by Beat Saber for years. And with a few small changes to the <a href="https://github.com/WiVRn/WiVRn/blob/master/docs/steamvr.md">command-line arguments in Steam</a>, I had vanilla Beat Saber running on my headset almost immediately.
Interestingly, performance seemed to be even better on Linux than Windows; I was streaming at 120fps (on Windows I limited to 72) and my audio latency was down by nearly a third (~60ms compared to 90 on Windows).
This isn't uncommon, quite a few benchmarks a game running on Linux can <a href="https://arstechnica.com/gaming/2025/06/games-run-faster-on-steamos-than-windows-11-ars-testing-finds/">outperform its Windows counterpart</a>.</p>
<p>Finally, I wanted to get modded Beat Saber running. Luckily BSManager provides a Linux flatpak that installed without issue, and I could download and mod versions just like on Windows.
Getting it running on the headset proved to be a much bigger challenge. Out of the box, the WiVRn app list on the device wasn't detecting the modded Beat Saber, so I couldn't start it.</p>
<p>With some help from the BSManager Discord, I found the set of environment variables that needed set (within BSManager) to get it to launch. But the workflow was pretty horrendous: start WiVRn on the PC, then launch the client on the headset. Then take the headset <em>off</em> and launch Beat Saber on the PC. Then, put the headset back on and start playing.</p>
<p><img src="https://www.steele.blue/img/zUKLvPJTjP-600.png" alt="Screenshot of Beat Saber running on a Bazzite Linux desktop" decoding="async" loading="lazy" width="1920" height="1080" srcset="https://www.steele.blue/img/zUKLvPJTjP-600.png 600w, https://www.steele.blue/img/zUKLvPJTjP-1000.png 1000w, https://www.steele.blue/img/zUKLvPJTjP-1920.png 1920w" sizes="100vw"></p>
<p>It turns out there were a few bugs in both BSManager and WiVRn that prevented the app from being discovered, and launching properly. With some trial and error (and reading a bit more C++ than I was ready for), I was able to get it running. The details are in <a href="https://github.com/Zagrios/bs-manager/issues/968#issuecomment-3766039142">this GitHub Issue</a> - and fixes have already landed in the WiVRn codebase.</p>
<p>This feels like a telling anecdote where the Linux gaming ecosystem is at: the fundamental components to play even complicated games are solid, and have been steadily improving. And when things don't work, there's a community to help address it. Hopefully more of this knowledge will get pulled out of private Discords and into public knowledge bases (or simply fixed at the root).</p>
<h2>I can tinker with Linux when I need to cool down</h2>
<p>I feel like I've just scratched the surface of what a Linux gaming PC can do. My library is pretty small, but I expect to grow it, especially with the <a href="https://store.steampowered.com/sale/steamframe">Steam Frame</a> inbound, offering another VR headset running Linux directly.</p>
<p>Of course there are other neat features you can try with a Linux machine with a decent GPU. Bluefin's AI strategy is one of local operation and control. So if you want to experiment with LLMs, you can <a href="https://docs.projectbluefin.io/ai/#ramalama">install Ramalama</a>, pull down a model from Huggingface you're comfortable with, and use your own GPU and resources. It feels like a breath of fresh air to have control of what's running on your machine, compared to the Windows experience of "what fresh hell with this restart bring today".</p>
]]></description>
      <pubDate>Sun, 25 Jan 2026 00:00:00 +0000</pubDate>
      <link>https://www.steele.blue/linux-desktop-zwift-beat-saber/</link>
      <dc:creator>Matt Steele</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5095033904</guid>
    </item>
    <item>
      <title><![CDATA[Almost Plain Text, Nicely Done]]></title>
      <description><![CDATA[
<p class="wp-block-paragraph">Just got two emails that I thought were quite nice. It reminds me of an opinion I’ve really held strong for quite a while. I even <a href="https://frontendmasters.com/blog/simple-typographic-email-template/">made a template for it one time</a>, and I wrote:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">if we go for HTML email, we can apply&nbsp;<em>just enough</em>&nbsp;layout and styles to make it almost like a plain text email, only with a bit more class.</p>



<ul class="wp-block-list">
<li>A pleasant readable typeface</li>



<li>Reasonable line length</li>



<li>Breathable line height</li>



<li>Anchor links on words</li>



<li>Dark/light mode</li>
</ul>
</blockquote>



<p class="wp-block-paragraph">Here’s one from Cursor:</p>



<figure class="wp-block-image size-large"><img data-recalc-dims="1" loading="lazy" decoding="async" width="1024" height="996" data-attachment-id="9846" data-permalink="https://email-is-good.com/2026/01/15/almost-plain-text-nicely-done/screenshot-2026-01-15-at-6-34-13-am/" data-orig-file="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?fit=1552%2C1510&amp;ssl=1" data-orig-size="1552,1510" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="Screenshot 2026-01-15 at 6.34.13 AM" data-image-description="" data-image-caption="" data-medium-file="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?fit=300%2C292&amp;ssl=1" data-large-file="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?fit=1024%2C996&amp;ssl=1" src="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=1024%2C996&amp;ssl=1" alt="Email notification about the release of GPT-5.2 Codex in Cursor, addressed to Chris, highlighting its capabilities for long-running tasks." class="wp-image-9846" srcset="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=1024%2C996&amp;ssl=1 1024w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=300%2C292&amp;ssl=1 300w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=768%2C747&amp;ssl=1 768w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=1536%2C1494&amp;ssl=1 1536w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=1200%2C1168&amp;ssl=1 1200w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?resize=1320%2C1284&amp;ssl=1 1320w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.34.13-AM.png?w=1552&amp;ssl=1 1552w" sizes="auto, (max-width: 1000px) 100vw, 1000px"></figure>



<p class="wp-block-paragraph">I find a “button” style could be added to that short list of OK things in an otherwise chill HTML email. But I <em>really</em> like how short and sweet the email is. Begs to be actually read.</p>



<p class="wp-block-paragraph">And one from Mandy Brown:</p>



<figure class="wp-block-image size-large"><img data-recalc-dims="1" loading="lazy" decoding="async" width="1024" height="787" data-attachment-id="9848" data-permalink="https://email-is-good.com/2026/01/15/almost-plain-text-nicely-done/screenshot-2026-01-15-at-6-29-20-am/" data-orig-file="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?fit=1518%2C1166&amp;ssl=1" data-orig-size="1518,1166" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="Screenshot 2026-01-15 at 6.29.20 AM" data-image-description="" data-image-caption="" data-medium-file="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?fit=300%2C230&amp;ssl=1" data-large-file="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?fit=1024%2C787&amp;ssl=1" src="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?resize=1024%2C787&amp;ssl=1" alt="Email screenshot discussing the concept of 'burnout' and its evolving meanings, authored by Mandy Brown." class="wp-image-9848" srcset="https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?resize=1024%2C787&amp;ssl=1 1024w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?resize=300%2C230&amp;ssl=1 300w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?resize=768%2C590&amp;ssl=1 768w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?resize=1200%2C922&amp;ssl=1 1200w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?resize=1320%2C1014&amp;ssl=1 1320w, https://i0.wp.com/email-is-good.com/wp-content/uploads/2026/01/Screenshot-2026-01-15-at-6.29.20-AM.png?w=1518&amp;ssl=1 1518w" sizes="auto, (max-width: 1000px) 100vw, 1000px"></figure>



<p class="wp-block-paragraph">Probably the nicest typography of any newsletter ever. But really isn’t doing <em>that</em> much to pull it off, just a nice typeface with smart setting.</p>
]]></description>
      <pubDate>Fri, 16 Jan 2026 01:44:07 +0000</pubDate>
      <link>https://email-is-good.com/2026/01/15/almost-plain-text-nicely-done/</link>
      <dc:creator>Email is good.</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5084501228</guid>
    </item>
    <item>
      <title><![CDATA[Progressive Enhancement and Web Components]]></title>
      <description><![CDATA[Now that web components have gained more traction over the last few years (used by companies like Salesforce, Github, and Adobe), we should explore how web components can offer progressive enhancement to applications without the bloat of JavaScript frameworks.]]></description>
      <pubDate>Wed, 16 Jul 2025 10:00:00 +0000</pubDate>
      <link>https://sparkbox.com/foundry/progressive_enhancement_and_web_components</link>
      <dc:creator>The Foundry from Sparkbox</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4883179220</guid>
    </item>
    <item>
      <title><![CDATA[Testing HTML Light DOM Web Components: Easier Than Expected!]]></title>
      <description><![CDATA[
<figure class="wp-block-image size-large"><img width="1024" height="576" src="https://cloudfour.com/wp-content/uploads/2025/11/light-dom-wc-test-feature-r1-1024x576.jpg" alt="An HTML custom element, `<light-dom-component>` wrapping three topics designed with green checkmarks to illustrate passing tests: render to DOM, accessibility, events" class="wp-image-8432" srcset="https://cloudfour.com/wp-content/uploads/2025/11/light-dom-wc-test-feature-r1-1024x576.jpg 1024w, https://cloudfour.com/wp-content/uploads/2025/11/light-dom-wc-test-feature-r1-300x169.jpg 300w, https://cloudfour.com/wp-content/uploads/2025/11/light-dom-wc-test-feature-r1-768x432.jpg 768w, https://cloudfour.com/wp-content/uploads/2025/11/light-dom-wc-test-feature-r1-1536x864.jpg 1536w, https://cloudfour.com/wp-content/uploads/2025/11/light-dom-wc-test-feature-r1.avif 1600w" sizes="(max-width: 1024px) 100vw, 1024px"></figure>



<p>A recent project of ours involved <a href="https://cloudfour.com/topics/app-modernization/">modernizing</a> a large, decades-old legacy web application. Our fantastic design team redesigned the interfaces and created <a href="https://cloudfour.com/thinks/responsive-design-process-that-works/">in-browser prototypes</a> that we referenced throughout development as we built HTML/CSS patterns and <a href="https://cloudfour.com/topics/web-components/">HTML web components</a>. For the web components, we used the <a href="https://meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/">Light DOM</a> and <a href="https://cloudfour.com/thinks/web-components-as-progressive-enhancement/">progressive enhancement</a> where possible, keeping accessibility front and center.</p>



<p>Going in, we weren’t sure how challenging it’d be to write tests for HTML web components. <strong>Spoiler alert:</strong> It wasn’t too different than testing framework-specific components (e.g., Vue or React) and in some cases, even easier! </p>



<p>For a project of this scope, a strong test suite was crucial, as it enabled us to focus on new features instead of constantly fixing regression bugs and repeating <em>manual</em> browser testing again and again. These are the patterns that worked well for us.</p>



<h2 class="wp-block-heading">The web component testing stack we used</h2>



<p>Our testing stack consisted of:</p>



<ul class="wp-block-list">
<li><a href="https://vitest.dev/">Vitest</a> – Fast, ESM-native test runner with Vite integration using a <a href="https://vitest.dev/config/environment.html#environment"><code>jsdom</code> environment</a></li>



<li>Lit’s <a href="https://lit.dev/docs/components/rendering/"><code>render()</code> function</a> and <a href="https://lit.dev/docs/templates/overview/"><code>html</code> templates</a> to render to the DOM</li>



<li>The <a href="https://testing-library.com/docs/dom-testing-library/intro/">DOM Testing Library</a> (no framework wrapper), along with:
<ul class="wp-block-list">
<li><a href="https://testing-library.com/docs/user-event/intro/"><code>@testing-library/user-event</code></a> – User interaction simulation</li>



<li><a href="https://testing-library.com/docs/ecosystem-jest-dom"><code>@testing-library/jest-dom</code></a> – DOM-specific matchers, for example <code>toBeVisible()</code>, <code>toBeChecked()</code>, and <code>toHaveFocus()</code></li>
</ul>
</li>



<li><a href="https://github.com/chaance/vitest-axe"><code>vitest-axe</code></a> – Automated accessibility rule checking (to catch low-hanging fruit)</li>



<li><a href="https://testing-library.com/docs/ecosystem-eslint-plugin-testing-library"><code>eslint-plugin-testing-library</code></a> and <a href="https://testing-library.com/docs/ecosystem-eslint-plugin-jest-dom"><code>eslint-plugin-jest-dom</code></a> ESLint plugins to help enforce testing best practices</li>
</ul>



<p>Most of these tools are standard. The interesting choice here is using Lit’s <code>html</code> and <code>render()</code> in the tests. We built our first (and one of the most complex) web components with Lit, then switched to vanilla web components for the rest. Since <code>lit</code> was already a dependency, we continued to use its <a href="https://lit.dev/docs/templates/overview/">templating features</a>, <a href="https://lit.dev/docs/templates/expressions/">expressions</a>, <a href="https://lit.dev/docs/templates/conditionals/">conditionals</a>, and <a href="https://lit.dev/docs/templates/directives/">directives</a> in our tests. A few of the benefits:</p>



<ul class="wp-block-list">
<li>Setting up new tests included no manual DOM manipulation</li>



<li>Provided a declarative HTML-like syntax with editor syntax highlighting</li>



<li>We were able to create shared parameterizable example/demo Lit templates (shared by tests <em>and</em> Storybook stories), which significantly reduced the boilerplate</li>



<li>Helped standardize how each test (and Storybook story) should be set up for easier maintenance</li>



<li>The rendered HTML is immediately in the DOM, which means Testing Library queries and standard DOM APIs work without special setup</li>



<li>Better TypeScript support within our tests</li>
</ul>



<p>Overall, a better, more efficient developer experience.</p>



<p class="has-gray-lighter-background-color has-background">Worth noting, there is also a <a href="https://lit.dev/docs/libraries/standalone-templates/">standalone <code>lit-html</code> library</a> that we could have used instead of the full <code>lit</code> package. <code>lit-html</code> provides <code>html</code> and <code>render</code>, as well as the directive modules. Today I learned. 🙂</p>



<h2 class="wp-block-heading">Light DOM web components simplified testing</h2>



<p>One of the most impactful early architectural decisions we made was to build all web components using <a href="https://meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/">the Light DOM</a> instead of Shadow DOM. While we didn’t realize it at the time, it dramatically simplified testing and component composition.</p>



<p>From a testing perspective, it meant we could query anything, anywhere, anytime:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript">render(
  html`<span class="xml">
    <span class="hljs-tag">&lt;<span class="hljs-name">my-amazing-component</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">my-tree-component</span> 
        <span class="hljs-attr">data</span>=</span></span><span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(jsonData())}</span><span class="xml"><span class="hljs-tag">
      &gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">my-tree-component</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">dialog</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">dialog</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">my-amazing-component</span>&gt;</span>
  `</span>,
  <span class="hljs-built_in">document</span>.body,
);</code></span></pre>


<p>With <strong>Shadow DOM</strong>, we’d need something like:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-comment">// ❌ What you'd have to do with Shadow DOM</span>
<span class="hljs-keyword">const</span> component = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'my-amazing-component'</span>);
<span class="hljs-keyword">const</span> shadowRoot = component.shadowRoot; <span class="hljs-comment">// May be null!</span>
<span class="hljs-keyword">const</span> button = shadowRoot?.querySelector(<span class="hljs-string">'button'</span>); <span class="hljs-comment">// Doesn't cross boundaries</span>
<span class="hljs-comment">// Or use special testing utilities that pierce shadow boundaries</span>
<span class="hljs-comment">// And if there are nested shadow boundaries, *eek!* 😬</span></code></span></pre>


<p>With <strong>Light DOM</strong>, it’s much simpler:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-comment">// ✅ What we actually do</span>
<span class="hljs-keyword">const</span> component = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'my-amazing-component'</span>);
<span class="hljs-keyword">const</span> button = component.querySelector(<span class="hljs-string">'button'</span>); <span class="hljs-comment">// Just works</span></code></span></pre>


<p>And <strong>Testing Library queries</strong> also <em>just work</em>:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript">render(validationExample(), <span class="hljs-built_in">document</span>.body);

<span class="hljs-comment">// screen.getByRole() finds elements INSIDE your components</span>
<span class="hljs-keyword">const</span> emailInput = screen.getByRole(<span class="hljs-string">'textbox'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-regexp">/email/i</span> });
<span class="hljs-keyword">const</span> submitBtn = screen.getByRole(<span class="hljs-string">'button'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-string">'Submit'</span> });

<span class="hljs-comment">// These work because there's no Shadow DOM boundary blocking queries</span>
<span class="hljs-keyword">await</span> user.click(submitBtn);
expect(emailInput).toHaveAttribute(<span class="hljs-string">'aria-invalid'</span>, <span class="hljs-string">'true'</span>);
expect(emailInput).toHaveFocus();</code></span></pre>


<p>Testing Library’s philosophy is “query like a user would.” With Light DOM web components, the mental model matches perfectly.</p>



<h2 class="wp-block-heading">Testing web component events</h2>



<p>Most, if not all, web components we built dispatched <a href="https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent">custom events with <code>detail</code> data</a>. We used the following Vitest features to confirm the expected event data:</p>



<ul class="wp-block-list">
<li><a href="https://vitest.dev/api/vi.html#vi-fn"><code>vi.fn()</code> function spy</a></li>



<li><a href="https://vitest.dev/api/expect.html#tohavebeencalledwith"><code>toHaveBeenCalledWith()</code> assertion</a></li>



<li><a href="https://vitest.dev/api/expect.html#expect-objectcontaining"><code>objectContaining()</code> assertion</a></li>
</ul>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table"><span class="shcb-loc"><span>it(<span class="hljs-string">'Emits change event when "Confirm" is clicked'</span>, <span class="hljs-keyword">async</span> () =&gt; {
</span></span><span class="shcb-loc"><span>  <span class="hljs-keyword">const</span> user = userEvent.setup();
</span></span><span class="shcb-loc"><span>  render(
</span></span><span class="shcb-loc"><span>    html`<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">my-tree-component</span> </span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag">      <span class="hljs-attr">data</span>=</span></span><span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(jsonData())}</span><span class="xml"><span class="hljs-tag"></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">    &gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">my-tree-component</span>&gt;</span>`</span>,</span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">    <span class="hljs-built_in">document</span>.body,</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  );</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-comment">// Set up the handler function spy for the 'change' listener</span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-comment">// Attached to `document` to confirm event bubbles</span></span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-keyword">const</span> changeHandler = vi.fn();</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">'my-tree-component-change'</span>, changeHandler);</span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-comment">// Click confirm button</span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-keyword">const</span> confirmButton = screen.getByRole(<span class="hljs-string">'button'</span>, { name: <span class="hljs-regexp">/^confirm$/i</span> });</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-keyword">await</span> user.click(confirmButton);</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-comment">// Event should be emitted with correct details</span></span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  expect(changeHandler).toHaveBeenCalledWith(</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">    expect.objectContaining({</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">      <span class="hljs-keyword">type</span>: <span class="hljs-string">'my-tree-component-change'</span>,</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">      detail: {</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">        action: <span class="hljs-string">'change'</span>,</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">        selectedEntityIds: [<span class="hljs-string">'test1'</span>],</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">      },</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">      bubbles: <span class="hljs-literal">true</span>,</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">    }),</span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  );</span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">	</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-comment">// Clean up the event listener</span></span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-built_in">document</span>.removeEventListener(<span class="hljs-string">'my-tree-component-change'</span>, changeHandler);</span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">});</span></span></span></span></span>
</span></span></code></span></pre>




<h2 class="wp-block-heading">Testing hidden inputs generated by web components</h2>



<p>One of our goals was to minimize the need for legacy backend code refactors. The legacy application relied on good ol’ traditional form submission architecture. Some of the legacy UI relied on JavaScript-generated hidden inputs that satisfied the backend code. Our new web components needed to match this behavior.</p>



<p>When testing the hidden inputs feature of a web component, Light DOM web components made it much simpler because any hidden inputs created by the web component are included in the form submission automatically:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript shcb-code-table"><span class="shcb-loc"><span>render(
</span></span><span class="shcb-loc"><span>  html`<span class="xml"></span>
</span></span><span class="shcb-loc"><span><span class="xml">    <span class="hljs-tag">&lt;<span class="hljs-name">form</span>&gt;</span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag">      <span class="hljs-tag">&lt;<span class="hljs-name">my-tree-component</span> </span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag">        <span class="hljs-attr">data</span>=</span></span><span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(jsonData())}</span><span class="xml"><span class="hljs-tag"></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">      &gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">my-tree-component</span>&gt;</span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">      <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"submit"</span>&gt;</span>Submit the form<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag">    <span class="hljs-tag">&lt;/<span class="hljs-name">form</span>&gt;</span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag">  `</span>,</span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag">  <span class="hljs-built_in">document</span>.body,</span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag">);</span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-comment">// Get the form</span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-keyword">const</span> form = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'form'</span>) <span class="hljs-keyword">as</span> HTMLFormElement;</span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-comment">// Hidden inputs are in Light DOM, so form submission includes them automatically</span></span></span></span></span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-keyword">const</span> hiddenInputs = form.querySelectorAll(<span class="hljs-string">'input[type="hidden"][name="entity"]'</span>);</span></span></span></span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag">expect(hiddenInputs).toHaveLength(<span class="hljs-number">5</span>);</span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-comment">// They participate in form submission naturally</span></span></span></span></span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">new</span> FormData(form);</span></span></span></span></span></span></span></span>
</span></mark><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-keyword">const</span> entities = formData.getAll(<span class="hljs-string">'entities'</span>);</span></span></span></span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-tag"><span class="hljs-tag">expect(entities).toHaveLength(<span class="hljs-number">5</span>);</span></span></span></span></span></span></span></span>
</span></span></code></span></pre>


<p>In some cases, we needed to confirm the hidden inputs rendered in a specific order with specific prefixed values. To test this, <a href="https://vitest.dev/api/expect.html#tomatch">Vitest’s <code>toMatch()</code> assertion with regular expressions</a> came in handy:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript">expect(entities[<span class="hljs-number">0</span>]).toMatch(<span class="hljs-regexp">/^OP(AND|OR|NOR|NAND)$/</span>);
expect(entities[<span class="hljs-number">1</span>]).toMatch(<span class="hljs-regexp">/^EL/</span>);
expect(entities[<span class="hljs-number">2</span>]).toMatch(<span class="hljs-regexp">/^OP(AND|OR|NOR|NAND)$/</span>);
expect(entities[<span class="hljs-number">3</span>]).toMatch(<span class="hljs-regexp">/^EL/</span>);
expect(entities[<span class="hljs-number">4</span>]).toMatch(<span class="hljs-regexp">/^EL/</span>);</code></span></pre>


<h2 class="wp-block-heading">Testing both attribute and property APIs</h2>



<p>Most of the web components supported both declarative (HTML attributes) and imperative (JavaScript properties) APIs. We added basic “render” tests for each use case:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript shcb-code-table"><span class="shcb-loc"><span>it(<span class="hljs-string">'Renders via `content` attribute'</span>, <span class="hljs-function"><span class="hljs-params">()</span> =&gt;</span> {
</span></span><span class="shcb-loc"><span>  render(
</span></span><span class="shcb-loc"><span>    html`<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">my-data-table</span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag">      <span class="hljs-attr">data</span>=</span></span><span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(jsonData())}</span><span class="xml"><span class="hljs-tag"></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">    &gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">my-data-table</span>&gt;</span>`</span>,</span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">    <span class="hljs-built_in">document</span>.body,</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  );</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  </span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-keyword">const</span> tableEl = screen.getByRole(<span class="hljs-string">'table'</span>);</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  expect(tableEl).toBeVisible();</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">});</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">it(<span class="hljs-string">'Renders via `content` property'</span>, <span class="hljs-function"><span class="hljs-params">()</span> =&gt;</span> {</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function">  render(html`<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">my-data-table</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">my-data-table</span>&gt;</span>`</span>, <span class="hljs-built_in">document</span>.body);</span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">		</span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">  <span class="hljs-comment">// Set the JSON data via the property</span></span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">  <span class="hljs-keyword">const</span> component = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'my-data-table'</span>) <span class="hljs-keyword">as</span> MyDataTable;</span></span></span></span></span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">  component.data = jsonData();</span></span></span></span></span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">  </span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">  <span class="hljs-keyword">const</span> tableEl = screen.getByRole(<span class="hljs-string">'table'</span>);</span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">  expect(tableEl).toBeVisible();</span></span></span></span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-function"><span class="xml"><span class="hljs-tag"><span class="hljs-tag">});</span></span></span></span></span></span></span></span></span>
</span></span></code></span></pre>


<h2 class="wp-block-heading">Typing web component references</h2>



<p>We wrote all web components and tests in TypeScript. This helped catch API changes anytime we refactored or fixed bugs. In tests where we wanted to access component properties or methods, we needed to add a <a href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions">type assertion</a> since <code>querySelector()</code> can possibly return <code>null</code>:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript shcb-code-table"><span class="shcb-loc"><span>render(
</span></span><span class="shcb-loc"><span>  html`<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">my-tree-component</span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag">    <span class="hljs-attr">data</span>=</span></span><span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(jsonData())}</span><span class="xml"><span class="hljs-tag"></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  &gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">my-tree-component</span>&gt;</span>`</span>,</span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-built_in">document</span>.body,</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">);</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-comment">// Use a type assertion since we know the element is in the DOM</span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-keyword">const</span> myTreeComponent = <span class="hljs-built_in">document</span>.querySelector(</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">  <span class="hljs-string">'my-tree-component'</span>,</span></span></span></span></span>
</span></span><mark class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">) <span class="hljs-keyword">as</span> MyTreeComponent; </span></span></span></span></span>
</span></mark><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag"><span class="hljs-comment">// Now you have full TypeScript support</span></span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">expect(myTreeComponent.treeNodes).toHaveLength(<span class="hljs-number">21</span>);</span></span></span></span></span>
</span></span><span class="shcb-loc"><span><span class="xml"><span class="hljs-tag"><span class="hljs-subst"><span class="xml"><span class="hljs-tag">myTreeComponent.data = newData; <span class="hljs-comment">// Type-safe property access</span></span></span></span></span></span>
</span></span></code></span></pre>


<h2 class="wp-block-heading">Tests and Storybook stories shared Lit <code>html</code> templates</h2>



<p>As mentioned earlier, sharing Lit <code>html</code> templates helped reduce boilerplate and standardized how we set up all tests and Storybook stories. Below is an example Lit <code>html</code> template for an input validator component:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-comment">// input-validator-example.ts</span>

<span class="hljs-keyword">import</span> { html } <span class="hljs-keyword">from</span> <span class="hljs-string">'lit'</span>;
<span class="hljs-keyword">import</span> { ifDefined } <span class="hljs-keyword">from</span> <span class="hljs-string">'lit/directives/if-defined.js'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> InputValidatorExampleArgs {
	<span class="hljs-keyword">type</span>: HTMLInputElement[<span class="hljs-string">'type'</span>];
	validationError?: <span class="hljs-built_in">string</span>;
	required?: <span class="hljs-built_in">boolean</span>;
	pattern?: <span class="hljs-built_in">string</span>;
	ariaDescribedby?: <span class="hljs-built_in">string</span>;
}

<span class="hljs-comment">/**
 * Used by tests and Storybook stories.
 */</span>
<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">inputValidatorExample</span>(<span class="hljs-params">{
	<span class="hljs-keyword">type</span>,
	validationError,
	required = <span class="hljs-literal">true</span>,
	pattern,
	ariaDescribedby,
}: InputValidatorExampleArgs</span>) </span>{
	<span class="hljs-keyword">const</span> inputId = <span class="hljs-string">`input-<span class="hljs-subst">${<span class="hljs-keyword">type</span>}</span>`</span>;
	<span class="hljs-keyword">let</span> label = <span class="hljs-keyword">type</span>.charAt(<span class="hljs-number">0</span>).toUpperCase() + <span class="hljs-keyword">type</span>.slice(<span class="hljs-number">1</span>);

	<span class="hljs-keyword">if</span> (<span class="hljs-keyword">type</span> === <span class="hljs-string">'select'</span>) {
		label = <span class="hljs-string">`<span class="hljs-subst">${label}</span> an option`</span>;
	}

	<span class="hljs-keyword">if</span> (<span class="hljs-keyword">type</span> === <span class="hljs-string">'text'</span> &amp;&amp; pattern) {
		label = <span class="hljs-string">`<span class="hljs-subst">${label}</span> with regex pattern`</span>;
	}

	<span class="hljs-keyword">let</span> field;
	<span class="hljs-keyword">if</span> (<span class="hljs-keyword">type</span> === <span class="hljs-string">'select'</span>) {
		field = html`<span class="xml">
			<span class="hljs-tag">&lt;<span class="hljs-name">select</span>
				<span class="hljs-attr">id</span>=</span></span><span class="hljs-subst">${inputId}</span><span class="xml"><span class="hljs-tag">
				?<span class="hljs-attr">required</span>=</span></span><span class="hljs-subst">${required}</span><span class="xml"><span class="hljs-tag">
				?<span class="hljs-attr">pattern</span>=</span></span><span class="hljs-subst">${pattern}</span><span class="xml"><span class="hljs-tag">
				<span class="hljs-attr">aria-describedby</span>=</span></span><span class="hljs-subst">${ifDefined(ariaDescribedby)}</span><span class="xml"><span class="hljs-tag">
			&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">""</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"1"</span>&gt;</span>Option 1<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"2"</span>&gt;</span>Option 2<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
				<span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"3"</span>&gt;</span>Option 3<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
			<span class="hljs-tag">&lt;/<span class="hljs-name">select</span>&gt;</span>
		`</span>;
	} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">type</span> === <span class="hljs-string">'textarea'</span>) {
		field = html`<span class="xml">
			<span class="hljs-tag">&lt;<span class="hljs-name">textarea</span>
				<span class="hljs-attr">id</span>=</span></span><span class="hljs-subst">${inputId}</span><span class="xml"><span class="hljs-tag">
				<span class="hljs-attr">minlength</span>=<span class="hljs-string">"10"</span>
				?<span class="hljs-attr">required</span>=</span></span><span class="hljs-subst">${required}</span><span class="xml"><span class="hljs-tag">
				?<span class="hljs-attr">pattern</span>=</span></span><span class="hljs-subst">${pattern}</span><span class="xml"><span class="hljs-tag">
				<span class="hljs-attr">aria-describedby</span>=</span></span><span class="hljs-subst">${ifDefined(ariaDescribedby)}</span><span class="xml"><span class="hljs-tag">
			&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">textarea</span>&gt;</span>
		`</span>;
	} <span class="hljs-keyword">else</span> {
		field = html`<span class="xml"> <span class="hljs-tag">&lt;<span class="hljs-name">input</span>
			<span class="hljs-attr">id</span>=</span></span><span class="hljs-subst">${inputId}</span><span class="xml"><span class="hljs-tag">
			<span class="hljs-attr">.type</span>=</span></span><span class="hljs-subst">${<span class="hljs-keyword">type</span>}</span><span class="xml"><span class="hljs-tag">
			<span class="hljs-attr">minlength</span>=</span></span><span class="hljs-subst">${ifDefined(<span class="hljs-keyword">type</span> === <span class="hljs-string">'password'</span> ? <span class="hljs-string">'5'</span> : <span class="hljs-literal">undefined</span>)}</span><span class="xml"><span class="hljs-tag">
			?<span class="hljs-attr">required</span>=</span></span><span class="hljs-subst">${required}</span><span class="xml"><span class="hljs-tag">
			<span class="hljs-attr">.pattern</span>=</span></span><span class="hljs-subst">${ifDefined(pattern) <span class="hljs-keyword">as</span> <span class="hljs-built_in">string</span>}</span><span class="xml"><span class="hljs-tag">
			<span class="hljs-attr">aria-describedby</span>=</span></span><span class="hljs-subst">${ifDefined(ariaDescribedby)}</span><span class="xml"><span class="hljs-tag">
		/&gt;</span>`</span>;
	}

	<span class="hljs-keyword">return</span> html`<span class="xml">
		<span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"form-group"</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=</span></span><span class="hljs-subst">${inputId}</span><span class="xml"><span class="hljs-tag">&gt;</span></span><span class="hljs-subst">${label}</span><span class="xml"><span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
			<span class="hljs-tag">&lt;<span class="hljs-name">input-validator</span> <span class="hljs-attr">validation-error</span>=</span></span><span class="hljs-subst">${ifDefined(validationError)}</span><span class="xml"><span class="hljs-tag">&gt;</span>
				</span><span class="hljs-subst">${field}</span><span class="xml">
			<span class="hljs-tag">&lt;/<span class="hljs-name">input-validator</span>&gt;</span>
		<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
	`</span>;
}</code></span></pre>


<p>This allowed us to use the same HTML for both tests and Storybook stories:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-comment">// InputValidator.test.ts</span>

render(inputValidatorExample({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'tel'</span> }), <span class="hljs-built_in">document</span>.body);</code></span></pre>

<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-comment">// InputValidator.stories.ts</span>

<span class="hljs-comment">/**
 * The component supports various `&lt;input&gt;` `type` values. Below are examples
 * of different input types that can be used with the component.
 */</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> VariousInputTypesSupported: Story = {
  render: <span class="hljs-function"><span class="hljs-params">()</span> =&gt;</span>
    html`<span class="hljs-subst">${[
      <span class="hljs-string">'email'</span>,
      <span class="hljs-string">'url'</span>,
      <span class="hljs-string">'password'</span>,
      <span class="hljs-string">'tel'</span>,
      <span class="hljs-string">'number'</span>,
      <span class="hljs-string">'date'</span>,
      <span class="hljs-string">'time'</span>,
      <span class="hljs-string">'datetime-local'</span>,
      <span class="hljs-string">'month'</span>,
      <span class="hljs-string">'week'</span>,
      <span class="hljs-string">'search'</span>,
      <span class="hljs-string">'text'</span>,
      <span class="hljs-string">'checkbox'</span>,
    ].map((<span class="hljs-keyword">type</span>) =&gt; inputValidatorExample({ <span class="hljs-keyword">type</span> }</span><span class="xml">))}`</span>,
};</code></span></pre>


<h2 class="wp-block-heading">Testing for accessibility</h2>



<p><a href="https://cloudfour.com/topics/accessibility/">Building accessible user interfaces</a> is something we believe in and strive for as a team. Our web component tests helped reinforce this <a href="https://cloudfour.com/thinks/cloud-fours-core-values/#we-%e2%9d%a4%ef%b8%8f-the-web">core value</a>.</p>



<h3 class="wp-block-heading">Every web component had an accessibility violation test assertion</h3>



<p>As a baseline standard practice, we always included a <code>vitest-axe</code> <code>toHaveNoViolations()</code> test assertion (including checking multiple UI states as needed):</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript shcb-code-table"><span class="shcb-loc"><span>it(<span class="hljs-string">'Has no accessibility violations'</span>, <span class="hljs-keyword">async</span> () =&gt; {
</span></span><span class="shcb-loc"><span>  render(formValidatorExample(), <span class="hljs-built_in">document</span>.body);
</span></span><span class="shcb-loc"><span>  
</span></span><span class="shcb-loc"><span>  <span class="hljs-keyword">const</span> component = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'form-validator'</span>) <span class="hljs-keyword">as</span> HTMLElement;  
</span></span><span class="shcb-loc"><span>  <span class="hljs-keyword">const</span> submitBtn = screen.getByRole(<span class="hljs-string">'button'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-string">'Submit'</span> });
</span></span><span class="shcb-loc"><span>
</span></span><span class="shcb-loc"><span>  <span class="hljs-comment">// Initial form state  </span>
</span></span><mark class="shcb-loc"><span>  expect(<span class="hljs-keyword">await</span> axe(component)).toHaveNoViolations();  
</span></mark><span class="shcb-loc"><span>
</span></span><span class="shcb-loc"><span>  <span class="hljs-comment">// Invalid form state</span>
</span></span><span class="shcb-loc"><span>  <span class="hljs-keyword">await</span> user.click(submitBtn);
</span></span><mark class="shcb-loc"><span>  expect(<span class="hljs-keyword">await</span> axe(component)).toHaveNoViolations();
</span></mark><span class="shcb-loc"><span>});
</span></span></code></span></pre>


<h3 class="wp-block-heading">Testing Library <code>ByRole</code> queries as the default query</h3>



<p>With all our tests, we’d default to querying the DOM using <a href="https://testing-library.com/docs/queries/byrole/">Testing Library’s <code>ByRole</code> queries</a> with accessible names. If a query fails, the control is not accessible (either an incorrect role or an incorrect/missing <a href="https://developer.mozilla.org/en-US/docs/Glossary/Accessible_name">accessible name</a>):</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-keyword">const</span> submitBtn = screen.getByRole(<span class="hljs-string">'button'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-string">'Submit'</span> });</code></span></pre>


<p class="has-gray-lighter-background-color has-background"><strong>Remember:</strong> The “<a href="https://www.w3.org/TR/using-aria/#rule1">first rule of ARIA</a>” is to prefer native, semantic HTML elements and only introduce <a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles"><em>ARIA</em> roles</a> as needed. In both cases, <code>ByRole</code> queries help confirm the proper role.</p>



<h3 class="wp-block-heading">Assertions for ARIA attributes where applicable</h3>



<p>In some cases, we’d assert certain ARIA attribute values where it made sense, for example:</p>


<pre class="wp-block-code"><span><code class="hljs language-bash">expect(input).toHaveAttribute(<span class="hljs-string">'aria-invalid'</span>, <span class="hljs-string">'false'</span>);</code></span></pre>


<h3 class="wp-block-heading">Testing focus management</h3>



<p>Keyboard users rely on proper focus management. For example, forms should move focus to the first invalid field on validation:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript shcb-code-table"><span class="shcb-loc"><span>it(<span class="hljs-string">'Validates an empty form on submit'</span>, <span class="hljs-keyword">async</span> () =&gt; {
</span></span><span class="shcb-loc"><span>  <span class="hljs-comment">// … setup</span>
</span></span><span class="shcb-loc"><span>    
</span></span><span class="shcb-loc"><span>  <span class="hljs-comment">// Submit empty form</span>
</span></span><span class="shcb-loc"><span>  <span class="hljs-keyword">await</span> user.click(submitBtn);
</span></span><span class="shcb-loc"><span>    
</span></span><span class="shcb-loc"><span>  <span class="hljs-comment">// Focus moves to first invalid field</span>
</span></span><mark class="shcb-loc"><span>  expect(emailInput).toHaveFocus();
</span></mark><span class="shcb-loc"><span>});
</span></span></code></span></pre>


<p>Other use cases where focus management assertions are important include testing that the focus returns to the appropriate elements after dialogs close or actions complete.</p>



<p class="has-gray-lighter-background-color has-background"><strong>Tip:</strong> As part of our development process, we use our keyboards to navigate through the UI. Did the <kbd>Tab</kbd> jump to the control we expected? Did we expect the <kbd>Escape</kbd> key to close a dialog? After submitting an invalid form, is the focus on the first invalid input? Not using a mouse and manually testing these scenarios with a keyboard helps guide the assertions we include in the tests.</p>



<h2 class="wp-block-heading">Test file organization</h2>



<p>As our test suite grew, we started organizing test files by <strong>feature</strong> or <strong>concern</strong>. This helped avoid monolithic <code>Component.test.ts</code> files with <em>hundreds</em> of tests. Here are the common test file categories we saw occur organically:</p>



<h3 class="wp-block-heading">Interaction tests: <code>ComponentName.interactions.test.ts</code></h3>



<p>Tests included assertions for user interactions, click handlers, keyboard navigation, and UI state changes in response to user actions.</p>



<h3 class="wp-block-heading">Event tests: <code>ComponentName.events.test.ts</code></h3>



<p>Tests included assertions for custom event emissions, event bubbling, and event payloads.</p>



<h3 class="wp-block-heading">Rendering tests: <code>ComponentName.rendering.test.ts</code></h3>



<p>Tests included assertions for initial render, conditional rendering, and DOM structure. </p>



<h3 class="wp-block-heading">Feature-specific tests: <code>ComponentName.feature.test.ts</code></h3>



<p>For specific features, we named test files after the feature:</p>



<ul class="wp-block-list">
<li><code>ComponentName.hidden-inputs.test.ts</code></li>



<li><code>ComponentName.sorting.test.ts</code></li>



<li><code>ComponentName.validation.test.ts</code></li>
</ul>



<h2 class="wp-block-heading">Directory structure patterns</h2>



<p>We preferred to collocate the test files next to the component or feature. This kept the test suite maintainable, discoverable, and faster to run. We found it easier to find and run tests for specific features.</p>



<h3 class="wp-block-heading">Pattern 1: Tests alongside component</h3>



<p>For simpler components:</p>


<pre class="wp-block-code"><span><code class="hljs shcb-code-table"><span class="shcb-loc"><span>ComponentName/
</span></span><span class="shcb-loc"><span>├── _component-name.css
</span></span><span class="shcb-loc"><span>├── ComponentName.ts
</span></span><span class="shcb-loc"><span>├── ComponentName.stories.ts
</span></span><mark class="shcb-loc"><span>├── ComponentName.interactions.test.ts
</span></mark><mark class="shcb-loc"><span>├── ComponentName.rendering.test.ts
</span></mark><mark class="shcb-loc"><span>└── ComponentName.events.test.ts
</span></mark><span class="shcb-loc"><span>
</span></span></code></span></pre>


<h3 class="wp-block-heading">Pattern 2: Feature sub-directories with tests</h3>



<p>For complex features that warrant their own folder:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript shcb-code-table"><span class="shcb-loc"><span>ComponentName/
</span></span><span class="shcb-loc"><span>├── _component-name.css
</span></span><span class="shcb-loc"><span>├── ComponentName.ts
</span></span><span class="shcb-loc"><span>├── ComponentName.stories.ts
</span></span><span class="shcb-loc"><span>├── validation/
</span></span><span class="shcb-loc"><span>│   ├── use-validation.ts
</span></span><mark class="shcb-loc"><span>│   ├── ComponentName.validation.test.ts
</span></mark><mark class="shcb-loc"><span>│   ├── ComponentName.validation.initial-render.test.ts
</span></mark><mark class="shcb-loc"><span>│   └── ComponentName.validation.attribute-changes.test.ts
</span></mark><span class="shcb-loc"><span>├── single-select/
</span></span><span class="shcb-loc"><span>│   ├── component-name-single-select-example.ts <span class="hljs-comment">// The `html` template for tests</span>
</span></span><mark class="shcb-loc"><span>│   └── ComponentName.single-select.test.ts
</span></mark><span class="shcb-loc"><span>├── multi-select/
</span></span><span class="shcb-loc"><span>│   ├── component-name-multi-select-example.ts <span class="hljs-comment">// The `html` template for tests</span>
</span></span><mark class="shcb-loc"><span>│   └── ComponentName.multi-select.test.ts
</span></mark><span class="shcb-loc"><span>└── pre-select/
</span></span><span class="shcb-loc"><span>    ├── use-pre-select.ts
</span></span><mark class="shcb-loc"><span>    ├── use-pre-select.test.ts
</span></mark><mark class="shcb-loc"><span>    └── use-pre-select.object-support.test.ts
</span></mark><span class="shcb-loc"><span>
</span></span></code></span></pre>


<h3 class="wp-block-heading">Pattern 3: Helper/utility tests</h3>



<p>Tests&nbsp;for&nbsp;pure&nbsp;functions&nbsp;and utilities:</p>


<pre class="wp-block-code"><span><code class="hljs language-accesslog shcb-code-table"><span class="shcb-loc"><span>MyTreeComponent/
</span></span><span class="shcb-loc"><span>├── _my-tree-component.css
</span></span><span class="shcb-loc"><span>├── MyTreeComponent.ts
</span></span><span class="shcb-loc"><span>├── MyTreeComponent.interactions.test.ts
</span></span><span class="shcb-loc"><span>├── MyTreeComponent.events.test.ts
</span></span><span class="shcb-loc"><span>└── helpers/
</span></span><span class="shcb-loc"><span>    ├── flatten-tree.ts
</span></span><mark class="shcb-loc"><span>    ├── flatten-tree.test.ts
</span></mark><span class="shcb-loc"><span>    ├── generate-node-id.ts
</span></span><mark class="shcb-loc"><span>    ├── generate-node-id.test.ts
</span></mark><span class="shcb-loc"><span>    ├── set-tree-ids.ts
</span></span><mark class="shcb-loc"><span>    └── set-tree-ids.test.ts
</span></mark><span class="shcb-loc"><span>
</span></span></code></span></pre>


<h2 class="wp-block-heading">Using a <code>for</code>/<code>of</code> loop to run repetitive test assertions</h2>



<p>This pattern isn’t groundbreaking, but there were times when we used an array of input names and a <code>for</code>/<code>of</code> loop to run the same assertions against multiple inputs.</p>



<p>For example, without a loop:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-keyword">const</span> dataTable = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'data-table'</span>) <span class="hljs-keyword">as</span> DataTable;

expect(within(dataTable).getAllByRole(<span class="hljs-string">'checkbox'</span>)).toHaveLength(<span class="hljs-number">6</span>);

<span class="hljs-keyword">const</span> selectAllCheckbox = within(dataTable).getByRole(
	<span class="hljs-string">'checkbox'</span>, 
	{ <span class="hljs-attr">name</span>: <span class="hljs-string">'Select all'</span> }
);
expect(selectAllCheckbox).toBeVisible();
expect(selectAllCheckbox).toBeChecked();

<span class="hljs-keyword">const</span> entity01Checkbox = within(dataTable).getByRole(
	<span class="hljs-string">'checkbox'</span>, 
	{ <span class="hljs-attr">name</span>: <span class="hljs-string">'Select Entity_01'</span> }
);
expect(entity01Checkbox).toBeVisible();
expect(entity01Checkbox).toBeChecked();

<span class="hljs-keyword">const</span> entity02Checkbox = within(dataTable).getByRole(
	<span class="hljs-string">'checkbox'</span>, 
	{ <span class="hljs-attr">name</span>: <span class="hljs-string">'Select Entity_02'</span> }
);
expect(entity02Checkbox).toBeVisible();
expect(entity02Checkbox).toBeChecked();

<span class="hljs-keyword">const</span> entity03Checkbox = within(dataTable).getByRole(
	<span class="hljs-string">'checkbox'</span>, 
	{ <span class="hljs-attr">name</span>: <span class="hljs-string">'Select Entity_03'</span> }
);
expect(entity03Checkbox).toBeVisible();
expect(entity03Checkbox).toBeChecked();

<span class="hljs-keyword">const</span> entity04Checkbox = within(dataTable).getByRole(
	<span class="hljs-string">'checkbox'</span>, 
	{ <span class="hljs-attr">name</span>: <span class="hljs-string">'Select Entity_04'</span> }
);
expect(entity04Checkbox).toBeVisible();
expect(entity04Checkbox).toBeChecked();

<span class="hljs-keyword">const</span> entity05Checkbox = within(dataTable).getByRole(
	<span class="hljs-string">'checkbox'</span>, 
	{ <span class="hljs-attr">name</span>: <span class="hljs-string">'Select Entity_05'</span> }
);
expect(entity05Checkbox).toBeVisible();
expect(entity05Checkbox).toBeChecked();

</code></span></pre>


<p>Using a <code>for</code>/<code>of</code> loop:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-keyword">const</span> checkboxNames = [
	<span class="hljs-string">'Select all'</span>,
	<span class="hljs-string">'Select Entity_01'</span>,
	<span class="hljs-string">'Select Entity_02'</span>,
	<span class="hljs-string">'Select Entity_03'</span>,
	<span class="hljs-string">'Select Entity_04'</span>,
	<span class="hljs-string">'Select Entity_05'</span>,
];
<span class="hljs-keyword">const</span> dataTable = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'data-table'</span>) <span class="hljs-keyword">as</span> DataTable;

expect(within(dataTable).getAllByRole(<span class="hljs-string">'checkbox'</span>)).toHaveLength(
	checkboxNames.length,
);
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> name <span class="hljs-keyword">of</span> checkboxNames) {
	<span class="hljs-keyword">const</span> checkbox = within(dataTable).getByRole(<span class="hljs-string">'checkbox'</span>, { name });
	expect(checkbox).toBeVisible();
	expect(checkbox).toBeChecked();
}
</code></span></pre>


<p>Using the <code>for</code>/<code>of</code> loop<em> felt</em> cleaner and was easier to maintain.</p>



<h2 class="wp-block-heading">Thinking critically about clicking various controls at once</h2>



<p>This is less a pattern and more a reminder for our future selves to not just accept all ESLint rules without critical thinking.</p>



<p>Initially, we did the following:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-comment">// Expand each of the root nodes.</span>
<span class="hljs-keyword">const</span> tools = screen.getAllByRole(<span class="hljs-string">'group'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-regexp">/^tool\\\\./i</span> });
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> tool <span class="hljs-keyword">of</span> tools) {
	<span class="hljs-keyword">await</span> user.click(tool);
}

<span class="hljs-comment">// Expand each of the second-level nodes.</span>
<span class="hljs-keyword">const</span> services = screen.getAllByRole(<span class="hljs-string">'group'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-regexp">/^service\\\\./i</span> });
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> service <span class="hljs-keyword">of</span> services) {
	<span class="hljs-keyword">await</span> user.click(service);
}
</code></span></pre>


<p>However, <a href="https://eslint.org/docs/latest/rules/no-await-in-loop">the <code>no-await-in-loop</code> ESLint rule</a> flagged the <code>await</code> within the <code>for</code>/<code>of</code> loop. <em>Technically,</em> the rule is correct:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>Performing an operation on each element of an iterable is a common task. However, performing an <code>await</code> as part of each operation may indicate that the program is not taking full advantage of the parallelization benefits of <code>async</code>/<code>await</code>.</p>



<p>Often, the code can be refactored to create all the promises at once, then get access to the results using <code>Promise.all()</code> (or one of the other <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#promise_concurrency">promise concurrency methods</a>). Otherwise, each successive operation will not start until the previous one has completed.</p>
</blockquote>



<p>The ESLint rule suggested the following:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-comment">// Expand each of the root nodes.</span>
<span class="hljs-keyword">const</span> tools = screen.getAllByRole(<span class="hljs-string">'group'</span>, { name: <span class="hljs-regexp">/^tool\\\\./i</span> });
<span class="hljs-keyword">await</span> <span class="hljs-built_in">Promise</span>.all(tools.map(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-params">tool</span></span>) =&gt;</span> user.click(tool)));

<span class="hljs-comment">// Expand each of the second-level nodes.</span>
<span class="hljs-keyword">const</span> services = screen.getAllByRole(<span class="hljs-string">'group'</span>, { name: <span class="hljs-regexp">/^service\\\\./i</span> });
<span class="hljs-keyword">await</span> <span class="hljs-built_in">Promise</span>.all(services.map(<span class="hljs-function">(<span class="hljs-params"><span class="hljs-params">service</span></span>) =&gt;</span> user.click(service)));
</code></span></pre>


<p>That <em>makes sense</em>. However, for our use case, we want each successive operation to wait until the previous one has completed. Imagine a user clicking various UI controls. The user will not be clicking <em>all of them at once</em>, it’d be impossible! Instead, the user would click the controls one by one. Our tests should match how a user will interact with our UI. Additionally, if the DOM updates after each click, a race condition may occur, potentially making the test flaky.</p>



<p>We ended up turning off the <code>no-await-in-loop</code> rule in each of those lines with a comment to help explain <em>why</em> we disabled the ESLint rule:</p>


<pre class="wp-block-code"><span><code class="hljs language-javascript"><span class="hljs-comment">// Expand each of the root nodes.</span>
<span class="hljs-keyword">const</span> tools = screen.getAllByRole(<span class="hljs-string">'group'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-regexp">/^tool\\\\./i</span> });
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> tool <span class="hljs-keyword">of</span> tools) {
	<span class="hljs-comment">// We want to run the clicks sequentially to avoid UI race conditions.</span>
	<span class="hljs-comment">// Additionally, this closer aligns with how a real user would interact with the UI.</span>
	<span class="hljs-comment">// eslint-disable-next-line no-await-in-loop</span>
	<span class="hljs-keyword">await</span> user.click(tool);
}

<span class="hljs-comment">// Expand each of the second-level nodes.</span>
<span class="hljs-keyword">const</span> services = screen.getAllByRole(<span class="hljs-string">'group'</span>, { <span class="hljs-attr">name</span>: <span class="hljs-regexp">/^service\\\\./i</span> });
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> checkbox <span class="hljs-keyword">of</span> individualCheckboxes) {
	<span class="hljs-comment">// We want to run the clicks sequentially to avoid UI race conditions.</span>
	<span class="hljs-comment">// Additionally, this closer aligns with how a real user would interact with the UI.</span>
	<span class="hljs-comment">// eslint-disable-next-line no-await-in-loop</span>
	<span class="hljs-keyword">await</span> user.click(checkbox);
}
</code></span></pre>


<h2 class="wp-block-heading">Avoid leaking state between tests</h2>



<p>An important detail I want to highlight in particular: We need to reset the document body after each test run to avoid leaking state. In our case, we set this up globally in our <code>vitest-setup.ts</code> config file using the <a href="https://vitest.dev/api/#aftereach">Vitest <code>afterEach()</code> teardown function</a>. That way, we wouldn’t have to worry about manually adding this in each test or accidentally forgetting:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-comment">// vitest-setup.ts</span>

<span class="hljs-comment">/**
 * We are using Lit's `render` and `html` functions to render in the tests.
 * We need to reset the document body after each test to avoid leaking state.
 */</span>
afterEach(<span class="hljs-function"><span class="hljs-params">()</span> =&gt;</span> {
  render(html`<span class="xml">`</span>, <span class="hljs-built_in">document</span>.body);
});</code></span></pre>


<h2 class="wp-block-heading">Adding basic dialog functionality to jsdom</h2>



<p>Our tests used <code>jsdom</code>. There is an <a href="https://github.com/jsdom/jsdom/issues/3294">open <code>jsdom</code> <code>HTMLDialogElement</code> issue</a> that remains unresolved. We ended up mocking the <code>HTMLDialogElement</code> <code>show()</code>, <code>showModal()</code>, and <code>close()</code> methods in the <code>vitest-setup.ts</code> file as follows:</p>


<pre class="wp-block-code"><span><code class="hljs language-typescript"><span class="hljs-comment">// vitest-setup.ts</span>

<span class="hljs-comment">// Add basic dialog functionality to jsdom</span>
<span class="hljs-comment">// @see https://github.com/jsdom/jsdom/issues/3294</span>
HTMLDialogElement.prototype.show = vi.fn(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">mock</span>(<span class="hljs-params">
  <span class="hljs-keyword">this</span>: HTMLDialogElement,
</span>) </span>{
  <span class="hljs-keyword">this</span>.open = <span class="hljs-literal">true</span>;
});
HTMLDialogElement.prototype.showModal = vi.fn(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">mock</span>(<span class="hljs-params">
  <span class="hljs-keyword">this</span>: HTMLDialogElement,
</span>) </span>{
  <span class="hljs-keyword">this</span>.open = <span class="hljs-literal">true</span>;
});
HTMLDialogElement.prototype.close = vi.fn(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">mock</span>(<span class="hljs-params">
  <span class="hljs-keyword">this</span>: HTMLDialogElement,
</span>) </span>{
  <span class="hljs-keyword">this</span>.open = <span class="hljs-literal">false</span>;
});</code></span></pre>


<p>This <a href="https://github.com/jsdom/jsdom/issues/3294#issuecomment-1268330372">workaround</a> allowed us to keep moving forward with any tests that included HTML dialog element assertions.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p>At the beginning of the project, I was feeling a bit nervous because I wasn’t sure how easy it would be to test HTML web components. Choosing to build Light DOM web components was <em>absolutely</em> the right choice, and once we got rolling, it made testing HTML web components no different from testing framework-specific components. I’m elated to have gone through this experience, and I must say, I love me some <a href="https://cloudfour.com/topics/web-components/">HTML web components</a>. ❤️</p>



<h2 class="wp-block-heading">More resources</h2>



<ul class="wp-block-list">
<li>Testing Library has <a href="https://testing-library.com/docs/dom-testing-library/api-accessibility">accessibility-focused helper functions</a> that can help debug tests</li>



<li>Testing Library’s <a href="https://testing-library.com/docs/queries/about#priority">queries order of priority list</a> is a handy reference for building more accessible user interfaces</li>



<li><a href="https://kentcdodds.com/blog/common-mistakes-with-react-testing-library">Common mistakes with React Testing Library</a> by Kent C. Dodds (Even though it mentions React, all of the guidelines still apply for non-React projects)</li>



<li><a href="https://frontendmasters.com/blog/light-dom-only/">Light-DOM-Only Web Components are Sweet</a> by Chris Coyier</li>



<li><a href="https://www.youtube.com/watch?v=uIuf5LlA6KQ">Come to the light side: HTML Web Components</a> by Chris Ferdinandi (11ty Conf 2024 video)</li>



<li><a href="https://adactio.com/journal/20618">HTML Web Components</a> by Jeremy Keith</li>
</ul>



<p></p>

<hr>
<h2>We’re Cloud Four</h2>
<p>We solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.</p>

<p><a href="https://cloudfour.com/made/"><b>See our work</b></a></p>]]></description>
      <pubDate>Tue, 18 Nov 2025 16:30:00 +0000</pubDate>
      <link>https://cloudfour.com/thinks/testing-html-light-dom-web-components-easier-than-expected/</link>
      <dc:creator>Sharing what we learn about the responsive web – Cloud Four</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5020031108</guid>
    </item>
    <item>
      <title><![CDATA[Talking CSS, Web Components, App Design and (gulp) AI on ShopTalk Show]]></title>
      <description><![CDATA[
<p>I had a blast chatting with Chris and Dave on <a href="https://shoptalkshow.com/689/">episode 689 of ShopTalk Show</a>, my personal favorite podcast about building websites:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>In this episode we sit down with Tyler Sticka to discuss upgrading his project, Colorpeek. We explore the practical applications of web components and CSS, and how they are shaping the future of web development. Tyler shares his experiences with prototyping and the challenges of maintaining simplicity in design.</p>
</blockquote>



<p>You can listen right now <a href="https://plnk.to/shoptalk?to=page">wherever you subscribe to podcasts</a>, or in video form <a href="https://www.youtube.com/watch?v=6DK2k9qMj8o">on YouTube</a>:</p>



<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio"><div class="wp-block-embed__wrapper">
<iframe loading="lazy" title="Tyler Sticka on Colorpeek 2 and Awesome CSS (ep689)" width="500" height="281" src="https://www.youtube.com/embed/6DK2k9qMj8o?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=""></iframe>
</div></figure>



<p></p>

<hr>
<h2>We’re Cloud Four</h2>
<p>We solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.</p>

<p><a href="https://cloudfour.com/made/"><b>See our work</b></a></p>]]></description>
      <pubDate>Mon, 03 Nov 2025 18:25:04 +0000</pubDate>
      <link>https://cloudfour.com/thinks/talking-css-web-components-app-design-and-gulp-ai-on-shoptalk-show/</link>
      <dc:creator>Sharing what we learn about the responsive web – Cloud Four</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5002919835</guid>
    </item>
    <item>
      <title><![CDATA[Invisible success]]></title>
      <description><![CDATA[  <p>Eric Bailey has some great thoughts about why it’s <a href="https://ericwbailey.website/published/invisible-success/">tough to show success on a design systems team</a>:</p>
<blockquote>
<p>In a business context, design system work means numbers go down. Less bug reports, faster design iteration, shorter development cycles, fewer visual inconsistencies, smaller staffing requirements that enable folks to work on more interesting challenges, etc. All good things.</p>
<p>Unfortunately, contemporary business practices only reward numbers going up. There isn’t much infrastructure in place to quantify the constant, silent, boring, predictable, round-the-clock passive successes of this aspect of design systems after the initial effort is complete.</p>
</blockquote>
<p>This is an interesting discussion because it’s hard to quantify what’s good work when it comes to design systems. For example, when I was on a team like that I would struggle to make the case for refactoring tons of janky and confusing <span class="small-caps">CSS</span>.</p>
<p>Ultimately I felt like I was good at the systems part—I could tell when there were too many similar components or when we should refactor something—but I was really, really bad at the storytelling part of design systems. And that kind of work is really more story than systems: explaining to folks up the chain why this seemingly insignificant series of tasks is an investment in the future. Or why this one tiny detail is worthy of our care today so we don’t have to worry about it later.</p>  ]]></description>
      <pubDate>Fri, 19 Apr 2024 18:13:15 +0000</pubDate>
      <link>https://www.csscade.com/invisible-success</link>
      <dc:creator>The Cascade</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4430363646</guid>
    </item>
    <item>
      <title><![CDATA[VS Code – highlight just the active indent guide]]></title>
      <description><![CDATA[Long term readers may wonder – VS Code, Ben? Really? Let’s not getting into that now, instead, just the issue before us. The problem before us is that we want to highlight just the active indent guide of the code in the editor. By default, VS Code highlights both the active indent level, and also, […]]]></description>
      <pubDate>Thu, 08 Jan 2026 22:39:57 +0000</pubDate>
      <link>https://benfrain.com/vs-code-highlight-just-the-active-indent-guide/</link>
      <dc:creator>Ben Frain</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5076633239</guid>
    </item>
    <item>
      <title><![CDATA[Review: MoErgo Go60, a split ergonomic and fully programmable keyboard]]></title>
      <description><![CDATA[I’m going to call this from the start. The Go60 is the best travel-friendly split keyboard, perhaps even the best all-around split keyboard, you can buy right now. I’m no MoErgo fanboy – in my review of their Glove80 I was pretty clear that there were areas I felt the Glove80 fell short. I feel […]]]></description>
      <pubDate>Sat, 06 Dec 2025 18:35:20 +0000</pubDate>
      <link>https://benfrain.com/review-moergo-go60-a-split-ergonomic-and-fully-programmable-keyboard/</link>
      <dc:creator>Ben Frain</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5039791549</guid>
    </item>
    <item>
      <title><![CDATA[How our dog increased my appreciation for accessibility]]></title>
      <description><![CDATA[
<figure class="wp-block-image size-large"><img width="1024" height="576" src="https://cloudfour.com/wp-content/uploads/2025/08/coco-feature-r2-1024x576.jpg" alt="An illustrated brown and white spotted dog with its tongue hanging out and a goofy look on its face wags its tail. A thought bubble says, &quot;a11y?&quot;" class="wp-image-8382" srcset="https://cloudfour.com/wp-content/uploads/2025/08/coco-feature-r2-1024x576.jpg 1024w, https://cloudfour.com/wp-content/uploads/2025/08/coco-feature-r2-300x169.jpg 300w, https://cloudfour.com/wp-content/uploads/2025/08/coco-feature-r2-768x432.jpg 768w, https://cloudfour.com/wp-content/uploads/2025/08/coco-feature-r2-1536x864.jpg 1536w, https://cloudfour.com/wp-content/uploads/2025/08/coco-feature-r2.jpg 1600w" sizes="(max-width: 1024px) 100vw, 1024px"></figure>



<p>“<strong>Ouch!</strong> WTF was that Coco?”</p>



<p>It was Easter morning. I was bent over wiping Sophie’s feet. Sophie is my mom’s one-year-old cocker spaniel. We were dog sitting, and Coco, our husky pitbull mutt, was thrilled. In her excitement, Coco whipped around and headbutted me. </p>



<p>I yelled loud enough that our oldest came running to see what happened. I told them I was fine. I’d have a lump on my head or a black eye, but I was okay. </p>



<p>Or I so thought until the world started spinning. I sat down on the couch in hopes things would stabilize. I tried to look at my phone to figure out if I could take Tylenol or Ibuprofen. The screen was blurry and made me feel sick.</p>



<p>At urgent care later, the doctor confirmed what we already suspected. Coco had given me a concussion.</p>



<h2 class="wp-block-heading">A dark room with no screens</h2>



<p>The doctor’s prescription was simple. Give your brain a rest from all stimulus for three to four days. Assuming your concussion isn’t dangerous—and mine wasn’t—that means stay in a dark room with no screens. </p>



<p>While I lay in bed with blackout curtains closed and a mask over my eyes, I learned to appreciate some features of our HomePod Mini that I had never used before:</p>



<ul class="wp-block-list">
<li>If I asked Siri to text someone, the HomePod would ping when they replied and I could ask to have the message read aloud.</li>



<li>Siri could call someone through the HomePod speaker.</li>
</ul>



<p>These aren’t earth-shattering features, but they helped me feel connected when I couldn’t use screens or ear buds.</p>



<h2 class="wp-block-heading">My new favorite accessibility features</h2>



<p>When I felt well enough to try working again, I found it necessary to make several changes to my computer.</p>



<h3 class="wp-block-heading">Reduce motion is your friend</h3>



<p>Animation and motion would cause pain in my temple. This persisted for weeks after the concussion. I once had to leave a local meetup early because the presenter used  animated gifs in their presentation, and it triggered concussion symptoms.</p>



<figure class="wp-block-image size-large"><img width="1024" height="798" src="https://cloudfour.com/wp-content/uploads/2025/08/reduce-motion-1024x798.png" alt="" class="wp-image-8380" srcset="https://cloudfour.com/wp-content/uploads/2025/08/reduce-motion-1024x798.png 1024w, https://cloudfour.com/wp-content/uploads/2025/08/reduce-motion-300x234.png 300w, https://cloudfour.com/wp-content/uploads/2025/08/reduce-motion-768x598.png 768w, https://cloudfour.com/wp-content/uploads/2025/08/reduce-motion.png 1430w" sizes="(max-width: 1024px) 100vw, 1024px"><figcaption class="wp-element-caption">The reduce motion option in the macOS accessibility settings. </figcaption></figure>



<p>I have since returned to normal motion on my iPhone, but I still have reduced motion set on my desktop machine. Too much movement on a larger screen can still be  overwhelming. I’ve also turned off auto-play of animated images wherever possible.</p>



<h3 class="wp-block-heading">Dark mode isn’t only a fad</h3>



<p>I’ll admit that I’ve been pretty dismissive of dark mode. I thought it was something developers came up with because they like working in the dark.</p>



<p>But even though dark mode isn’t included in accessibility settings, I now think of it as one. Bright lights were too much for me and dark mode helped. It has moved up my priority list for the next version of our own site.</p>



<p>As an aside, Gmail has a dark theme, but it doesn’t turn on automatically when someone has <code>prefers-color-scheme</code> set to <code>dark</code>. That seems silly to me. The hard part is developing a dark version of your app or site. Once you have one, don’t make people search around for it.</p>



<figure class="wp-block-image size-large"><img width="1024" height="518" src="https://cloudfour.com/wp-content/uploads/2025/08/gmail-dark-theme-1024x518.jpg" alt="" class="wp-image-8381" srcset="https://cloudfour.com/wp-content/uploads/2025/08/gmail-dark-theme-1024x518.jpg 1024w, https://cloudfour.com/wp-content/uploads/2025/08/gmail-dark-theme-300x152.jpg 300w, https://cloudfour.com/wp-content/uploads/2025/08/gmail-dark-theme-768x389.jpg 768w, https://cloudfour.com/wp-content/uploads/2025/08/gmail-dark-theme-1536x778.jpg 1536w, https://cloudfour.com/wp-content/uploads/2025/08/gmail-dark-theme.jpg 1600w" sizes="(max-width: 1024px) 100vw, 1024px"><figcaption class="wp-element-caption">Gmail’s dark theme can be found by clicking on the gear icon to open settings. Then select the Themes tab and the Set Theme button. You’ll need to scroll down to find the Dark theme. Hover (sigh) over the blocks to see the theme name.</figcaption></figure>



<h3 class="wp-block-heading">You can use Night Shift mode during the day</h3>



<p>Apple’s <a href="https://support.apple.com/en-us/102191">Night Shift mode</a> shifts the color of a display to be warmer in the evening because “studies have shown that exposure to bright blue light in the evening can affect your circadian rhythms and make it harder to fall asleep.”</p>



<p>The slightly warmer colors in Night Mode were easier for my brain to process after the concussion. I left it on all the time.</p>



<h2 class="wp-block-heading">Accessibility is for everyone</h2>



<p>My concussion gave me a greater appreciation for the accessibility features that operating systems and browsers have built. If you worked on those features, I want to thank you for everything you do. If you built your website to honor <code>prefers-reduced-motion</code> and dark mode, I want you to know that your work made a difference to me.</p>



<p>It’s a misnomer to think that accessibility is only for people with permanent conditions. I like the way Maria Town, president of the American Association of People with Disabilities, talked about this reality in an <a href="https://www.advocate.com/politics/2020/7/13/disabled-advocate-everyone-will-become-disabled-if-theyre-lucky">interview with Advocate magazine</a>:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>Everyone will become disabled if they’re lucky enough. Aging is a privilege. Far too few of us get the opportunity to live to be a ripe old age. And if you do get the opportunity, you will likely become disabled.</p>
</blockquote>



<p>This is our reality. Whether it is a temporary injury, a permanent condition, old age, or a literal boneheaded dog giving you a concussion, you will need accessibility features at some point in your life. </p>



<p>So let’s recommit to supporting accessibility in our own work not only to support those who need it now, but also because it is in our own self-interest. You never know when you may suddenly find yourself needing accessibility features.</p>



<p>P.S. Coco was fine. She has a hard head.</p>

<hr>
<h2>We’re Cloud Four</h2>
<p>We solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.</p>

<p><a href="https://cloudfour.com/made/"><b>See our work</b></a></p>]]></description>
      <pubDate>Mon, 11 Aug 2025 15:17:44 +0000</pubDate>
      <link>https://cloudfour.com/thinks/how-our-dog-increased-my-appreciation-for-accessibility/</link>
      <dc:creator>Sharing what we learn about the responsive web – Cloud Four</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4908835815</guid>
    </item>
    <item>
      <title><![CDATA[Common Threads]]></title>
      <description><![CDATA[Visualizing how musicals use motifs to tell stories.]]></description>
      <pubDate>Tue, 09 Dec 2025 08:00:00 +0000</pubDate>
      <link>https://pudding.cool/2025/12/motifs</link>
      <dc:creator>The Pudding</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5043054137</guid>
    </item>
    <item>
      <title><![CDATA[]]></title>
      <description><![CDATA[
      <p>Favourite tree doing favourite tree things.</p><figure><img src="https://paulrobertlloyd.com/media/2025/267/p1/1.jpg" alt="Luminous bright orange leaves on the branches of a maple tree." class="u-photo"></figure>
    ]]></description>
      <pubDate>Wed, 24 Sep 2025 19:37:23 +0000</pubDate>
      <link>https://paulrobertlloyd.com/2025/267/p1/</link>
      <dc:creator>Paul Robert Lloyd</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4956720841</guid>
    </item>
    <item>
      <title><![CDATA[Uncrate's 100-ish favorite things on Amazon]]></title>
      <description><![CDATA[<div style="float: left; margin: 0 15px 15px 0; padding: 0; border: 1px solid #000000;"><a href="https://uncrate.com/uncrates-100-ish-favorite-things-on-amazon/" rel="bookmark">





    
    



		<img src="https://uncrate.com/assets_c/2025/12/amazon-uncrate-100-thumb-960xauto-187187.jpg" class="t webfeedsFeaturedVisual" alt="Uncrate's 100-ish favorite things on Amazon" title="Uncrate's 100-ish favorite things on Amazon" width="960">
	</a></div>Our hundred (give or take) picks available on Amazon. <br><br>Visit <a href="https://uncrate.com/uncrates-100-ish-favorite-things-on-amazon/">Uncrate</a> for the full post.]]></description>
      <pubDate>Mon, 01 Dec 2025 17:31:15 +0000</pubDate>
      <link>https://uncrate.com/uncrates-100-ish-favorite-things-on-amazon/</link>
      <dc:creator>Uncrate</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5034071573</guid>
    </item>
    <item>
      <title><![CDATA[Against the protection of stocking frames.]]></title>
      <description><![CDATA[
      <p>I think it’s long past time I start discussing “artificial intelligence” (“AI”) as a failed technology. Specifically, that large language models (LLMs) have repeatedly and consistently failed to demonstrate value to anyone other than their investors and shareholders. The technology is a failure, and I’d like to invite you to join me in treating it as such.</p>
<p>I’m not the first one to land here, of course; the likes of <a href="https://karendhao.com/">Karen Hao</a>, <a href="https://thecon.ai/">Alex Hanna, Emily Bender</a>, and more have been on this beat longer than I have. And just to be clear, describing “AI” as a failure doesn’t mean it doesn’t have useful, individual applications; it’s possible you’re already thinking of some that matter to you. But I think it’s important to see those as exceptions to the technology’s overwhelming bias toward failure. In fact, I think describing the technology <em>as a thing that has failed</em> can be helpful in elevating what does actually work about it. Heck, maybe it’ll even help us <a href="https://www.anildash.com/2025/05/02/what-would-good-ai-look-like/">build a better alternative to it</a>.&nbsp;<sup class="footnote-ref"><a class="footnote" href="https://ethanmarcotte.com/wrote/against-stocking-frames/#fn-dream" id="fnref-fn-dream" aria-labelledby="title-footnotes">1</a></sup></p>
<p>In other words, approaching “AI” as failure opens up some really useful lines of thinking and criticism. I want to spend more time with them.</p>
<hr>
<p>Right, so: why do I think it’s a failure? Well, there are a few reasons.</p>
<p>The first is that as a product class, “AI” <em>is</em> a failed technology. I don’t think it’s controversial to suggest that LLMs haven’t measured up to any of the <a href="https://www.microsoft.com/en-us/microsoft-365/business-insights-ideas/resources/improving-productivity-with-ai-tools">lofty</a> <a href="https://openai.com/index/planning-for-agi-and-beyond/">promises</a> made by their vendors. But in more concrete terms, <a href="https://www.prnewswire.com/news-releases/two-thirds-of-shoppers-say-no-to-ai-shopping-assistants--trust-issues-could-slow-retails-ai-revolution-302385556.html">consumers dislike “AI” when it shows up in products</a>, and it makes them <a href="https://business.yougov.com/content/49622-artificial-intelligence-ai-generated-brand-advertising-how-do-consumers-around-the-world-feel-comfortable">actively mistrust the brands that employ it</a>. In other words, we’re some three years into the <a href="https://en.wikipedia.org/wiki/Gartner_hype_cycle">hype cycle</a>, and LLMs haven’t met any markers of success we’d apply to, well, literally any other technology.</p>
<p>This failure can’t be separated from the staggering social, cultural, and ecological costs associated with simply <em>using</em> these services: the <a href="https://www.bbc.com/news/articles/cy8gy7lv448o">environmental harms</a> baked into these platforms; <a href="https://variety.com/vip/content-owner-lawsuits-against-ai-companies-comprehensive-updated-index-1236101707/">the violent disregard for copyright</a> that brought them into being; the <a href="https://www.cbsnews.com/news/ai-chatbots-teens-suicide-parents-testify-congress/">real-world deaths they’ve potentially caused</a>; the <a href="https://uniglobalunion.org/news/ai-action-summit-africa-tech-workers/">workforce</a> of <a href="https://www.cmswire.com/digital-experience/he-helped-train-chatgpt-it-traumatized-him/">underpaid and traumatized</a> <a href="https://www.wired.com/story/hundreds-of-google-ai-workers-were-fired-amid-fight-over-working-conditions/">contractors</a> that are quite literally building these platforms; and many, many more. I mention these costs because this isn’t a case of a well-built technology failing to find its market. As a force for devastation and harm, “AI” is a wild success; but as a viable product it is, again, a failure.</p>
<p>And yet despite all of this, “AI” feels like it’s just <em>everywhere.</em> Consumers may not like or even trust “AI” features, but that hasn’t stopped product companies from shipping them. Corporations are constantly launching new LLM initiatives, often simply because of <a href="https://newsroom.ibm.com/2025-05-06-ibm-study-ceos-double-down-on-ai-while-navigating-enterprise-hurdles">“the risk of falling behind” their competitors</a>. What’s more, according to a recent MIT report, <a href="https://fortune.com/2025/08/18/mit-report-95-percent-generative-ai-pilots-at-companies-failing-cfo/">very nearly all corporate “AI” pilots fail</a>.</p>
<p>I want to suggest that the ubiquity of LLMs is another sign of the technology’s failure. It is not succeeding on its own merits. Rather, it is being propped up by terrifying amounts of investment capital, not to mention a recent glut of <a href="https://www.gsa.gov/about-us/newsroom/news-releases/gsa-announces-new-partnership-with-openai-delivering-deep-discount-to-chatgpt-08062025">government</a> <a href="https://www.gov.uk/government/news/openai-to-expand-uk-office-and-work-with-government-departments-to-turbocharge-the-uks-ai-infrastructure-and-transform-public-services">contracts</a>.&nbsp;<sup class="footnote-ref"><a class="footnote" href="https://ethanmarcotte.com/wrote/against-stocking-frames/#fn-founders" id="fnref-fn-founders" aria-labelledby="title-footnotes">2</a></sup> Without that fiscal support, I very much doubt LLMs would even exist at the scale they currently do.</p>
<p>So. The technology doesn’t deliver consistent results, much less desirable ones; what’s more, it extracts terrible costs to <em>not</em> reliably produce anything of value. It is fundamentally a failure. And yet, private companies and public institutions alike keep adopting it. Why is that?</p>
<p>From where I sit, the most consistent application of LLMs at work has been through top-down corporate mandate: a company’s leadership will suggest, urge, or outright require employees to incorporate “AI” in their work. <a href="https://zapier.com/blog/zapier-ai-first-hiring-leaning/">Zapier’s post on its “AI-first” mandate</a> is one recent example. At some point, the company decided to mandate “AI” usage across their organization, joining such august brands as <a href="https://www.techmeme.com/250407/p26#a250407p26">Shopify</a>, <a href="https://www.theverge.com/news/657594/duolingo-ai-first-replace-contract-workers">Duolingo</a>, and <a href="https://bsky.app/profile/kyleconrad.bsky.social/post/3lxkg6tlekk2a">Taco Bell</a>. But in this post from the summer, Zapier’s global head of talent talks about how the company’s expanding the size and scope of that initial mandate. Here’s the intro:</p>
<blockquote>
<p>Recently, we shared our <a href="https://zapier.com/blog/how-zapier-rolled-out-ai/">AI adoption playbook</a>, which showed that 89% of the Zapier team is already using AI in their daily work. But to make AI transformation truly sustainable, we have to start at the beginning: how we hire and onboard people into Zapier to build this future with us.</p>
</blockquote>
<p>I’ve written before about <a href="https://ethanmarcotte.com/wrote/design-system-adoption/">the problems with “adoption” as a success metric</a>: that “usage of a thing” doesn’t communicate anything about the quality of that usage, or about the health of the system overall. But despite that, Zapier’s moved beyond mandated adoption, and has begun changing its hiring and onboarding practices<span class="w"> —</span> including how it evaluates employee performance. How does an “AI” mandate show up in a performance review? I’m so glad you asked:</p>
<blockquote>
<p>Starting immediately, all new Zapier hires are expected to meet a minimum standard for AI fluency. That doesn’t mean deep technical expertise in every case<span class="w"> —</span> but it does mean showing a mindset of curiosity toward AI, a demonstrated willingness to experiment with it, and an ability to think strategically about how AI can amplify their work.</p>
<p>[…]</p>
<p>We map skills across four levels, keeping in mind that AI skills vary and are heavily role-specific.</p>
<ul>
<li><strong>Unacceptable</strong>: Resistant to AI tools and skeptical of their value</li>
<li><strong>Capable</strong>: Using the most popular tools, with likely under three months of hands-on experience</li>
<li><strong>Adoptive</strong>: Embedding AI in personal workflows, tuning prompts, chaining models, and automating tasks to boost efficiency</li>
<li><strong>Transformative</strong>: Uses AI not just as a tool but to rethink strategy and deliver user-facing value that wasn’t possible a couple years ago</li>
</ul>
</blockquote>
<p>There’s an insidious thing nestled in here.</p>
<p><a href="https://piccalil.li/blog/are-peoples-bosses-really-making-them-use-ai/">Andy Bell</a> and <a href="https://www.bloodinthemachine.com/p/how-ai-is-killing-jobs-in-the-tech-f39">Brian Merchant</a> have both documented tech workers’ reactions to “AI” mandates: what it feels like to have parts of your job outsourced to automation, and how it changes what it feels like to show up for work. I’d recommend reading both posts in full; it’s possible you’ll see something of your own feelings mirrored in those testimonials. And those stories track with my own conversations with tech workers, who’ve shared how difficult it is to talk openly about their concerns at work. I’ve heard repeatedly about a kind of stifling social pressure: an implicit, unstated expectation that “AI” has to be seen as good and useful; pointing out limitations or raising questions feels difficult, if not dangerous.</p>
<p>But this Zapier post is the first example I’ve seen of a company making that implicit expectation into an explicit one. Here, the official policy is that <em>attitude toward a technology</em> should be used as a quantifiable measurement of how well a person aligns with the company’s goals: what the industry has historically (and euphemistically) referred to as <a href="https://www.bbc.com/worklife/article/20211015-what-does-being-a-cultural-fit-actually-mean">culture fit</a>. At this company, you could receive a negative performance review for being perceived as “resistant” or “skeptical” of LLMs. You’d be labeled as <code>unacceptable</code>.</p>
<p>I mean, look: on the face of it, that’s absurd. <em>That is absurd behavior.</em> Imagine screening prospective hires by asking their opinions about your company’s hosting provider, or evaluating employees for how they feel about Microsoft Teams. Just to be clear, I fully believe evaluations like these have happened in the industry<span class="w"> —</span> hiring and performance reviews are both riddled with bias, especially in tech. But this is the first time I’ve seen a company policy explicitly state that acceptance of “AI” is <em>a matter of cultural compliance</em>. That you’re either on board with “artificial intelligence,” or you’re not one of us.</p>
<p>This is where I think approaching “AI” as a failure becomes useful, even vital. It underscores that the technology’s real value isn’t improving productivity, or even in improving products. Rather, it’s a social mechanism employed to ensure compliance in the workplace, and to weaken worker power. Stories like the one at Zapier are becoming more common, where executive fiat is being used to force employees to use a technology that could <a href="https://ethanmarcotte.com/wrote/tooled/">deskill</a> them, and make them more replaceable. Arguably, this is the one use case where “artificial intelligence” has delivered some measure of consistent, provable results.</p>
<p>But here’s the thing: this is a success <em>only</em> if tech workers allow it to be. I’m convinced we can turn this into a failure, too. And we do that by getting organized.</p>
<p><span class="w"> —</span> okay, yes, I know. I am the person who thinks <a href="https://ethanmarcotte.com/books/you-deserve-a-tech-union/">you deserve a union</a>. But it’s not just me: from <a href="https://kotaku.com/diablo-4-blizzard-union-ai-layoffs-microsoft-2000621003">game studios</a> to <a href="https://newsguild.org/guild-members-are-winning-strong-protections-from-employer-pushed-ai/">newsrooms</a>, many workers are unionizing specifically because they want contractual protections from “artificial intelligence.” Heck, <a href="https://www.wbur.org/cognoscenti/2023/08/15/hollywood-writers-actors-strike-generative-artificial-intelligence-ethan-marcotte">the twin strikes in Hollywood</a> weren’t about banning “AI,” but giving workers control over how and when the technology was employed. I think at minimum, we deserve that level of control over our work.</p>
<p>With all that said, you don’t have to be unionized to start <em>organizing</em>: to have conversations with your coworkers, to share how you’re feeling about these changes at work, and start talking about what you’d like to do about those changes, together. It really is that simple.</p>
<p>That isn’t to say organizing is easy, mind. It involves having many, many conversations with your coworkers, and looking for shared concerns about issues in the workplace. And, look: I’m writing this post at a time where the labor market’s tight, when there’s so much pressure to not just adopt LLMs but to accept them unquestioningly. In that context, I realize that inviting coworkers to share some thoughts about automation can feel difficult, if not dangerous. But it’s only by organizing<span class="w"> —</span> by talking and listening to each other, and acting together in solidarity<span class="w"> —</span> do we have a chance at building a better, safer version of the tech industry.</p>
<p>“Artificial intelligence” is a failure. Let’s you and I make sure it stays that way.</p>
<hr>
    <div class="footnotes flow">
      <h2 class="hed-footnotes has-dash" id="title-footnotes">Footnotes</h2>
      <ol><li id="fn-dream" class="footnote-item"><p>I’m profoundly skeptical this could ever happen under capitalism. But what the hell, I’m willing to hypothesize a bit.&nbsp;<a href="https://ethanmarcotte.com/wrote/against-stocking-frames/#fnref-fn-dream" class="reversefootnote">↩︎</a></p>
</li>
<li id="fn-founders" class="footnote-item"><p>To keep this too-long post a little less long, I’m not going to spend any time talking about the founders of these platforms, <a href="https://www.theverge.com/policy/772760/tech-ceos-ai-trump-white-house-dinner">their wholesale embrace</a> of the United States’ current far-right administration, and selling said administration tools to visit harm upon <a href="https://www.americanimmigrationcouncil.org/blog/ice-immigrationos-palantir-ai-track-immigrants/">marginalized communities</a> and <a href="https://www.motherjones.com/politics/2025/04/clearview-ai-immigration-ice-fbi-surveillance-facial-recognition-hoan-ton-that-hal-lambert-trump/">political opponents</a> alike. But please believe I <em>am</em> thinking about them.&nbsp;<a href="https://ethanmarcotte.com/wrote/against-stocking-frames/#fnref-fn-founders" class="reversefootnote">↩︎</a></p>
</li>
  </ol>
  </div>
      
      <hr>
      <p>This has been “<a href="https://ethanmarcotte.com/wrote/against-stocking-frames/">Against the protection of stocking frames.</a>” a post from <a href="https://ethanmarcotte.com/wrote/">Ethan’s journal.</a></p>
      <p><a href="mailto:listener+rss@ethanmarcotte.com?subject=Reply%20to:%20“Against the protection of stocking frames.”">Reply via email</a></p>
      
    ]]></description>
      <pubDate>Thu, 18 Sep 2025 04:00:00 +0000</pubDate>
      <link>https://ethanmarcotte.com/wrote/against-stocking-frames/</link>
      <dc:creator>My journal — Ethan Marcotte’s website</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4949870126</guid>
    </item>
    <item>
      <title><![CDATA[The line and the stream.]]></title>
      <description><![CDATA[
      <p>I come from one of the poorest corners of a small, rural state. When I go back to visit, I book a night or two in this big hotel in my neck of the woods. The trips are heartwarming, and this last one was no exception: I got a chance to share a couple meals with old friends and family, surrounded by the hills and forests I still think of as home. This year has felt long and dark, and their faces and voices brightened it.</p>
<p>This hotel, though. Let me tell you about it.</p>
<p>It’s new, first of all. Even if you’re driving through the area for the first time, you’ll be able to spot “new construction” as something that sticks out. What’s more, this huge hotel’s been dropped on top of a mountain that’s too tiny for it. This isn’t one of the hot tourist destinations, either; this mountain’s home to a humble little ski resort, one that’s always been overshadowed by more popular tourist destinations, all of them a short drive away. Now, I need to stress here that I’m no skier<span class="w"> —</span> I did a single season on my high school cross-country skiing team that we will <em>not</em> be discussing<span class="w"> —</span> but I do know this resort’s historically drawn most of its customers from nearby towns: they’re not pulling in the wealthy out-of-staters. And to look at this new, massive, shiny hotel, that’s who you’d think they’re attracting.</p>
<p>How’d it get here? Well, we need to back up a decade or two to answer that. Because as it happens, some investor types decided to defraud the federal government.</p>
<p>They didn’t lead with that, of course. Through a visa program for affluent foreign investors, they had access to a tremendous amount of capital, which they then used to acquire local tourist spots and dramatically overdevelop them. As they did, they promised that all this investment would bring an influx of new businesses, which would mean more people moving to a state starved for both residents and revenue.</p>
<p>As you might have guessed, the money disappeared when these investors got arrested. One visible aftershock was the halted construction projects. In one town up north, there’s a crater the size of a literal city block, because all the new construction these investors promised<span class="w"> —</span> a slew of shops and offices, a new hotel<span class="w"> —</span> disappeared when they did. The investors are gone; the hole isn’t.</p>
<p>It also left some massive, sprawling properties in communities that never had enough foot traffic to properly support them. And here we are, back at this gigantic hotel. When we walk in, we see that the gift shop right off the lobby has been hastily boarded up. One wing of the hotel’s been blocked off to guests. At the check-in desk, the clerk has three registration forms lined up in front of her, one for each of the guests she knows will be checking in that evening. All because some rich, powerful men promised my chronically impoverished corner of the state that the things it had always and desperately needed<span class="w"> —</span> investment, jobs, and people<span class="w"> —</span> were, at long, long last, finally about to arrive.</p>
<hr>
<p>“Artificial intelligence is here to stay. It’s not going anywhere.” I’ve heard some variation on that line many, many times <a href="https://en.wikipedia.org/wiki/ChatGPT">since 2022</a>; I heard it a few more times after <a href="https://ethanmarcotte.com/wrote/against-stocking-frames/">defining “artificial intelligence” as a failed technology</a>. Maybe you’ve heard it too.</p>
<p>Now, regardless of how you or I might feel about “AI,” I think we can both acknowledge just how impressively ahistorical that statement is. In the last two decades, I’ve seen so many things we <em>knew</em> were our future: Internet Explorer, Flash, Web 2.0, jQuery, mobile websites, NFTs&nbsp;<sup class="footnote-ref"><a class="footnote" href="https://ethanmarcotte.com/wrote/the-line-and-the-stream/#fn-nfts" id="fnref-fn-nfts" aria-labelledby="title-footnotes">1</a></sup>, and more. We were told each and every single one of them was immutable, fixed, and unchanging; each and every single one of them, in turn, faded. Some of them even disappeared altogether. I’ve been in this industry long enough to know that predicted futures have a pretty short lifespan.</p>
<p>And that’s the moment in which I’m writing: that vision of an “AI” future feels like it’s shaking. In fact, it might be close to shattering. There’s a growing body of reporting on <a href="https://www.ft.com/content/6cc87bd9-cb2f-4f82-99c5-c38748986a2e">the rampant speculation at the heart of the current boom</a>, with some journalists gaming out <a href="https://www.theatlantic.com/technology/2025/10/data-centers-ai-crash/684765/">what a market crash</a> might look like. <a href="https://au.investing.com/news/stock-market-news/peter-thiel-dumps-entire-nvidia-stake-slashes-tesla-holdings-amid-bubble-fears-4128704">Investors</a> are <a href="https://www.bbc.com/audio/play/p0m7h23s">nervous</a>; leaders of “AI” companies are even starting <a href="https://www.cnbc.com/2025/08/18/openai-sam-altman-warns-ai-market-is-in-a-bubble.html">to sound</a> <a href="https://www.bbc.com/news/articles/cwy7vrd8k4eo">warning bells</a>, presumably to position themselves for government bailouts whenever the bubble finally bursts. In the face of all this instability, stating that “AI isn’t going anywhere” feels even more detached from reality: it’s divorced from how quickly trends shift in tech, and it’s ignoring the growing cracks in the industry’s foundation.</p>
<p>At the same time, I think that unwavering belief is what’s instructive about it.</p>
<p>I mean, look. I’ve been thinking about <em>my</em> future quite a bit. The last decade’s made my sense of the coming decades feel fractured, and the last year has only accelerated that feeling. Friends are aging; family members have passed on; so many things I was taught to rely upon<span class="w"> —</span> jobs, industries, institutions, milestones, even <em>seasons</em><span class="w"> —</span> feel like they’re being upended in front of me. When you’re told to expect a certain broad arc to your life, it’s more than a little terrifying when that map’s redrawn as you’re looking at it.</p>
<p>That’s why I’ve come to realize that statements about the future aren’t predictions: they’re more like spells. When someone describes <code>something</code> to you as the future, they’re sharing a heartfelt belief that this <code>something</code> will be part of whatever comes next. “Artificial intelligence isn’t going anywhere” quite literally involves casting a technology forward into time. How could that be anything else <em>but</em> a kind of magic?</p>
<p>Now. By calling them magical, I’m not attempting to diminish or disparage these kinds of statements. Far from it: when we make these statements, we’re opening up a kind of possibility space. “What <em>if</em> the future looked like this?” By making space to ask questions, we’re making the future feel less fixed, and far less daunting. It gives us an anchor point in what comes next, one we can move toward with intention. On an individual level, incantations like these can be a helpful goal-setting exercise; performed at scale, they can be transformative.</p>
<p>And not necessarily in a good way. Take the “AI” industry: backed by their belief in their (again, <a href="https://ethanmarcotte.com/wrote/against-stocking-frames/">failed</a>) technology <em>and</em> a ghastly amount of capital, they’ve embedded their technology in products, organizations, and governments alike. They’ve begun physical buildouts of <a href="https://www.bloomberg.com/news/newsletters/2025-08-24/why-is-manhattan-being-crushed-by-this-giant-meta-data-center">vast data centers</a> that take <a href="https://westvirginiawatch.com/2025/09/02/enough-devastation-mingo-logan-residents-worry-about-proposed-power-plants-data-centers-in-wv/">a tremendous toll</a> on <a href="https://www.mpbonline.org/blogs/news/construction-on-metas-largest-data-center-brings-600-crash-spike-chaos-to-rural-louisiana/">disenfranchised</a> <a href="https://www.politico.com/news/2025/05/06/elon-musk-xai-memphis-gas-turbines-air-pollution-permits-00317582">communities</a>, and are demanding <a href="https://www.abc.net.au/news/2025-10-28/google-microsoft-restarting-nuclear-plants-for-ai-power/105941378">older, even more dangerous forms of energy</a> to support their expansion. They’ve created <a href="https://www.theguardian.com/technology/2025/sep/11/google-gemini-ai-training-humans">new classes</a> of <a href="https://jacobin.com/2025/06/ai-moderation-ndas-trauma-labor">invisible workers</a> to power their operations, all while selling automation technology that makes work <a href="https://www.bloodinthemachine.com/p/how-ai-is-killing-jobs-in-the-tech-f39">even more precarious</a> for <a href="https://thelocal.to/investigating-scam-journalism-ai/">everyone</a> <a href="https://www.404media.co/teachers-are-not-ok-ai-chatgpt/">else</a>. This is all to say that they’ve moved very, very, very quickly to realize their vision for the future in the last three years. Hell, it’s hard to imagine a future <em>without</em> these platforms, and <a href="https://www.authoritarian-stack.info/">the powerful people</a> who sell them.</p>
<p>But to paraphrase something <a href="https://aworkinglibrary.com/">Mandy Brown</a> once said, there’s not going to be one future<span class="w"> —</span> there will be many. In fact, there’s another future being built right now.</p>
<p>Last fall, the workers at the <a href="https://www.nytimes.com/2024/12/11/business/media/new-york-times-tech-guild-deal.html">New York Times Tech Guild walked off the job for a week</a> and subsequently won their first union contract, one that improved pay, won them <a href="https://en.wikipedia.org/wiki/Just_cause_(employment_law)">“just cause” protections</a>, and more. For the last several months, <a href="https://www.kqed.org/news/12064555/protests-at-microsoft-conference-target-tech-giants-ties-with-israeli-military">workers at Microsoft have waged an effective and relentless pressure campaign</a> against their employer, in order to force the tech giant to stop selling technology that facilitates the genocide in Gaza. Last week, the workers at <a href="https://kickstarterunited.org/">Kickstarter United</a> ended their record-breaking strike, having walked off the job for forty-two days to secure improved pay equity, a contractually guaranteed four-day work week, and job protections from “AI”. This week, the workers at Starbucks United, who <a href="https://www.nocontractnocoffee.org/">began a strike just last week</a>, established <a href="https://bsky.app/profile/sbworkersunited.org/post/3m5yunxybjc2q">a blockade at one of the corporation’s distribution centers</a><span class="w"> —</span> all to bring management back to the table after months of stalled negotiations.</p>
<p>These are just four examples from the last year; there are many, many more. We’re surrounded by a tremendous amount of organizing right now, with workers banding together to build protections at work, to win improved pay and benefits, to advocate for human rights, to build <em>power</em> together. That organizing’s happening at a scale I find tremendously inspiring, and at a pace<span class="w"> —</span> <em>last year; this year; last week; this week</em><span class="w"> —</span> that’s steadily increasing. This is a new future, one being built by quiet conversations with coworkers, by petitions circulated at work, by bargaining sessions, by chants and songs and protests and strikes.</p>
<p>That’s not to say this future is any more assured than the other one: this worker-led future needs significantly more people, including you and your coworkers. But that aside, I’m struck by the contrast between the two. One vision for the future mentions technology and technology alone, neatly eliding right over who might be impacted or harmed if that future should come to pass; the other future is centered around humans, their needs, and their hopes for something better.</p>
<p>Me, I’ve decided which future I want to live in. When I wrote <a href="https://ethanmarcotte.com/books/you-deserve-a-tech-union/">my last book</a>, I said the future of the tech industry <em>is</em> an organized, worker-led labor movement. That’s the quiet hope I’m casting into the months and years and decades that are stretching before me.</p>
<p>Maybe you agree with that future. If you do, well<span class="w"> —</span> let’s you and I start moving toward it.</p>
<hr>
    <div class="footnotes flow">
      <h2 class="hed-footnotes has-dash" id="title-footnotes">Footnote</h2>
      <ol><li id="fn-nfts" class="footnote-item"><p>lol&nbsp;<a href="https://ethanmarcotte.com/wrote/the-line-and-the-stream/#fnref-fn-nfts" class="reversefootnote">↩︎</a></p>
</li>
  </ol>
  </div>
      
      <hr>
      <p>This has been “<a href="https://ethanmarcotte.com/wrote/the-line-and-the-stream/">The line and the stream.</a>” a post from <a href="https://ethanmarcotte.com/wrote/">Ethan’s journal.</a></p>
      <p><a href="mailto:listener+rss@ethanmarcotte.com?subject=Reply%20to:%20“The line and the stream.”">Reply via email</a></p>
      
    ]]></description>
      <pubDate>Fri, 21 Nov 2025 04:00:00 +0000</pubDate>
      <link>https://ethanmarcotte.com/wrote/the-line-and-the-stream/</link>
      <dc:creator>My journal — Ethan Marcotte’s website</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5023697298</guid>
    </item>
    <item>
      <title><![CDATA[Steam Machine]]></title>
      <description><![CDATA[<div style="float: left; margin: 0 15px 15px 0; padding: 0; border: 1px solid #000000;"><a href="https://uncrate.com/steam-machine/" rel="bookmark">





    
    



		<img src="https://uncrate.com/assets_c/2025/11/steam-machine-1-thumb-960xauto-186771.jpg" class="t webfeedsFeaturedVisual" alt="Steam Machine" title="Steam Machine" width="960">
	</a></div>Steam takes another swing at the living room with a gaming-focused, compact 6-inch cube PC.<br><br>Visit <a href="https://uncrate.com/steam-machine/">Uncrate</a> for the full post.]]></description>
      <pubDate>Thu, 13 Nov 2025 15:00:01 +0000</pubDate>
      <link>https://uncrate.com/steam-machine/</link>
      <dc:creator>Uncrate</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5014664447</guid>
    </item>
    <item>
      <title><![CDATA[Microsoft™ Ergonomic Keyboard (now sold by Incase)]]></title>
      <description><![CDATA[
<p>For my own long-term reference. </p>



<p>My favorite keyboard is the Microsoft Ergonomic Keyboard. But Microsoft is out of the keyboard hardware game. So apparently they sold the design to Incase, who now <a href="https://www.incase.com/collections/ergonomic-keyboard/products/ergonomic-keyboard">continues to sell it</a> at a perfectly fair price. </p>



<figure class="wp-block-image size-large"><img data-recalc-dims="1" loading="lazy" decoding="async" width="1024" height="930" data-attachment-id="12949" data-permalink="https://chriscoyier.net/2025/10/30/microsoft-ergonomic-keyboard-now-sold-by-incase/screenshot-2025-10-30-at-5-01-01-pm/" data-orig-file="https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?fit=1202%2C1092&amp;ssl=1" data-orig-size="1202,1092" data-comments-opened="1" data-image-meta="{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}" data-image-title="Screenshot 2025-10-30 at 5.01.01 PM" data-image-description="" data-image-caption="" data-medium-file="https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?fit=300%2C273&amp;ssl=1" data-large-file="https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?fit=1024%2C930&amp;ssl=1" src="https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?resize=1024%2C930&amp;ssl=1" alt="Top view of an Incase ergonomic keyboard with a curved design and wrist rest." class="wp-image-12949" srcset="https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?resize=1024%2C930&amp;ssl=1 1024w, https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?resize=300%2C273&amp;ssl=1 300w, https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?resize=768%2C698&amp;ssl=1 768w, https://i0.wp.com/chriscoyier.net/wp-content/uploads/2025/10/Screenshot-2025-10-30-at-5.01.01-PM.png?w=1202&amp;ssl=1 1202w" sizes="auto, (max-width: 1000px) 100vw, 1000px"></figure>
]]></description>
      <pubDate>Fri, 31 Oct 2025 00:02:34 +0000</pubDate>
      <link>https://chriscoyier.net/2025/10/30/microsoft-ergonomic-keyboard-now-sold-by-incase/</link>
      <dc:creator>Chris Coyier</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4999043584</guid>
    </item>
    <item>
      <title><![CDATA[Caira AI Mirrorless Camera]]></title>
      <description><![CDATA[<div style="float: left; margin: 0 15px 15px 0; padding: 0; border: 1px solid #000000;"><a href="https://uncrate.com/caira-ai-mirrorless-camera/" rel="bookmark">





    
    



		<img src="https://uncrate.com/assets_c/2025/11/caira-ai-camera-1-thumb-960xauto-186692.jpg" class="t webfeedsFeaturedVisual" alt="Caira AI Mirrorless Camera" title="Caira AI Mirrorless Camera" width="960">
	</a></div>The Caira camera mixes a micro fourth-thirds sensor with a MagSafe mount and AI capabilities.<br><br>Visit <a href="https://uncrate.com/caira-ai-mirrorless-camera/">Uncrate</a> for the full post.]]></description>
      <pubDate>Mon, 10 Nov 2025 23:00:01 +0000</pubDate>
      <link>https://uncrate.com/caira-ai-mirrorless-camera/</link>
      <dc:creator>Uncrate</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/5011240945</guid>
    </item>
    <item>
      <title><![CDATA[A new, new logo for the W3C]]></title>
      <description><![CDATA[<p>In an effort to pivot this site into a full on graphic design side business after 2 blog posts about logos in a row (hit me up exclusivly on <a href="https://fishbrain.com/anglers/miketaylr">FB</a> to request a consultation), I thought I would reveal my new, new logo for the W3C.</p>

<p>It turns out they recently launched a new one, but <a href="https://lists.w3.org/Archives/Public/www-archive/2025Oct/thread.html">some folks don’t love it</a>. As an artist, it’s not my job to critique other art, but instead to offer my own compelling vision for the web.</p>

<p><img src="https://miketaylr.com/posts/assets/w3c.png" style="border: 1px solid gray;" alt="a shitty drawing of a w, the word three spelled out, and followed by a period and the letter c"></p>

<p>I shouldn’t have to explain why I went with the classic dark blue and asparagus colors—that much is obvious. And of course, turning c into a file extension as a reminder that NCSA Mosaic was written in C (I didn’t go with <a href="https://en.wikipedia.org/wiki/WorldWideWeb">WorldWideWeb</a> because that was written in Objective C and <code class="language-plaintext highlighter-rouge">.m</code> kinda messes it all up).</p>
]]></description>
      <pubDate>Sat, 25 Oct 2025 04:00:00 +0000</pubDate>
      <link>https://miketaylr.com/posts/2025/10/new-new-logo-for-w3c.html</link>
      <dc:creator>Mike Taylr Dot Com Web Log</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4992628918</guid>
    </item>
    <item>
      <title><![CDATA[]]></title>
      <description><![CDATA[
<p><a href="https://blog.burkert.me/posts/in_praise_of_syndication/">Tom Burkert on controlling what he reads</a>, through RSS of course.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>If I’m in the mood for something lighter, I can just look into my “Fun” folder to check out new stuff from&nbsp;<a href="https://theoatmeal.com/" target="_blank" rel="noreferrer noopener">The Oatmeal</a>&nbsp;or&nbsp;<a href="https://xkcd.com/" target="_blank" rel="noreferrer noopener">xkcd</a>. If I feel like reading something more thoughtful, I’d dive into my “Reads” folder for&nbsp;<a href="https://www.themarginalian.org/" target="_blank" rel="noreferrer noopener">The Marginalian</a>&nbsp;or&nbsp;<a href="https://sentiers.media/" target="_blank" rel="noreferrer noopener">Sentiers</a>. Feeling like catching up on the newest AI research? I can browse the latest research papers from&nbsp;<a href="https://arxiv.org/" target="_blank" rel="noreferrer noopener">arXiv</a>&nbsp;that have specific keywords in the abstracts (such as prompt injection). Or I could just browse everything at once to see what piques my interest. I am the master of what information I consume, how and in what order, and no one can take that away from me by rearranging my feed or tweaking the algorithm.<br></p>
</blockquote>
]]></description>
      <pubDate>Fri, 24 Oct 2025 17:04:18 +0000</pubDate>
      <link>https://chriscoyier.net/2025/10/24/12937/</link>
      <dc:creator>Chris Coyier</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4991865910</guid>
    </item>
    <item>
      <title><![CDATA[A Treatise on AI Chatbots Undermining the Enlightenment]]></title>
      <description><![CDATA[On chatbot sycophancy, passivity, and the case for more intellectually challenging companions]]></description>
      <pubDate>Tue, 05 Aug 2025 00:00:00 +0000</pubDate>
      <link>https://maggieappleton.com/ai-enlightenment/</link>
      <dc:creator>Maggie Appleton</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4902292836</guid>
    </item>
    <item>
      <title><![CDATA[Speeding up my Learning Log process]]></title>
      <description><![CDATA[<p>Dave’s blog post <a href="https://daverupert.com/2025/05/obsidian-link-aggregator/">“How to make a Link Aggregator in Obsidian”</a> inspired me to finally make my Learning Log process a little smarter, starting with the May 2025 log.</p>
<h2 id="what-i-had-been-doing">What I had been doing</h2>
<ol>
<li>Automatically syncing my Reader by Readwise highlights into Obsidian</li>
<li>Going through the new articles manually and adding them to my blog post Markdown (also written in Obsidian)</li>
</ol>
<p>Obsidian enables you to customize to the nth degree, so I figured there was a better way to do this but hadn’t made it a priority. Dave’s post showed me I could do this pretty quickly!</p>
<h2 id="enter-dataview-plugin">Enter Dataview plugin</h2>
<p>I’d already installed the <a href="https://blacksmithgu.github.io/obsidian-dataview/">Dataview plugin</a> for Obsidian for one reason or another and ended up writing a DQL query very similar to Dave’s:</p>
<pre><code>LIST map(rows, (r) =&gt; elink(regexreplace(r.file.frontmatter.url, "\\?utm_.*", ""), r.title))
FROM "Resources/Readwise/Articles"
WHERE file.frontmatter.highlightedDate &gt;= "2025-08-01"
	AND file.frontmatter.highlightedDate &lt; "2025-09-01"
	AND file.frontmatter.url
FLATTEN file.tags AS tag
GROUP BY tag
SORT file.frontmatter.highlightedDate DESC
</code></pre>
<p>What this query does is:</p>
<ul>
<li>Pull all the articles I highlighted in a given month from the directory where I’ve synced my Readwise articles. It filters out any articles that don’t have a URL in the frontmatter, i.e. email newsletters.</li>
<li>Group the linked article titles by the tag I applied in Reader.</li>
</ul>
<p>I copy and paste this query into my blog post draft, update the tag groupings to be headers, and hand-curate the list a little further. I could use Dataview JS to structure this in a fancier way and skip some of this manual bit, but I find it’s worth slowing down at this curation stage.</p>
<p>You’ll notice I do have dates hardcoded in here as well. As we all know, the hardest problems in computer science are:</p>
<ul>
<li>People problems</li>
<li>Naming things</li>
<li>Wrangling dates</li>
</ul>
<p>I kept getting weird errors and issues with data handling, so decided to just throw up my hands and enter the dates manually. I only have to update them once a month, and so <a href="https://xkcd.com/1205/">this XKCD comic</a> seemed apt.</p>
<p>In any case, this was a nice little quality of life update for my blogging process, and I’m now playing with all things Dataview in my Obsidian notes!</p>
]]></description>
      <pubDate>Mon, 18 Aug 2025 00:00:00 +0000</pubDate>
      <link>https://melanie-richards.com/blog/speeding-up-learning-logs/</link>
      <dc:creator>Melanie Richards</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4916582122</guid>
    </item>
    <item>
      <title><![CDATA[Eight years of Jessie]]></title>
      <description><![CDATA[<p>I am currently regretting the posts I didn't make. Yes, as someone who bangs on and on about posting things on your blog first and then elsewhere, I too fail many times. And, it's been like that when it comes to Jessie.</p>
<p>Since we adopted Jessie in 2017, I've been celebrating her adoption aniversary on social media instead of this blog. And it bit me in the arse. I used to have a lovely thread going on for years on Twitter but, of course, I haven't touched that hell space in a very long time. I was lazy and now I am paying the price. For this year's anniversary I still posted on <a href="https://bsky.app/profile/ohhelloana.blog/post/3lw5ds3y2zc2y">BlueSky</a> and <a href="https://front-end.social/@anarodrigues/115011366161406914">Mastodon</a> but, I want to make sure that memory is captured here too.</p>
<p>Sweet Jessie has been with us for eight years now and, fun fact, she is about to turn 14! She was five years old when we adopted her. It's been a priviledge that the majority of her life has now been with us.</p>
<p><img src="https://ohhelloana.blog/assets/posts/jessie/jessie_eight.jpeg" alt="Jessie, a black and white cat, standing by a plate with cat food with a lit candle with the number eight."></p>
<p>Since living with us she has:</p>
<ul>
<li>lived in three different homes;</li>
<li>brought a mouse in only once (and it was a baby so it doesn't count);</li>
<li>went missing once (the worst);</li>
<li>has had dentist appointments and teeth removed;</li>
<li>became a big sister to an human;</li>
</ul>
<p><img src="https://ohhelloana.blog/assets/posts/jessie/jessie_seven-years.jpeg" alt="Collage of seven photos of my cat Jessie standing by cat food with a candle on top. In each photo the candle number changes to represent the adoption anniversary. Except for year 6 as there was no numbered candles available so it is a normal candle."></p>
<p>I really hope I have many more years of posts to make. 💗</p>
]]></description>
      <pubDate>Sat, 16 Aug 2025 00:00:00 +0000</pubDate>
      <link>https://ohhelloana.blog/eight-years-of-jessie/</link>
      <dc:creator>Ana Rodrigues</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4914276715</guid>
    </item>
    <item>
      <title><![CDATA[“Why would anybody start a website?”]]></title>
      <description><![CDATA[<p>Slowly catching up on my RSS feed and I just read <a href="https://daverupert.com/2025/09/why-would-anybody-start-a-website/">Dave's post “Why would anybody start a website?”</a>. Obviously, Dave's post is right up my street but it reminded me of a random notes post on my phone that I wrote a while back.</p>
<p>Why build a website?</p>
<p>Why knit a jumper with your bare hands?</p>
<p>Why cook a homemade meal from scratch?</p>
<p>Why paint in a canvas?</p>
<p>Why fix a broken thing?</p>
<p>Why write a letter?</p>
<p>Why anything really?</p>
<p>We, humans, are driven to touch and craft with our body. Deep down we crave that. Website making is a digital craft.</p>
<p>There, I let out a draft from my phone and it is now in my blog. Why not?</p>
<p>Anyway, I love how the "no thought is original" is real. Turns out, one day before I started this draft on my blog, <a href="https://blog.jim-nielsen.com/2025/why-make-a-website-in-2025/">Jim Nielsen</a> also did a similar comparison. They're smarter than me, listen to them!</p>
<hr>
<a href="https://news.indieweb.org/en" class="u-syndication">
  Also posted on IndieNews
</a>
]]></description>
      <pubDate>Thu, 18 Sep 2025 00:00:00 +0000</pubDate>
      <link>https://ohhelloana.blog/why/</link>
      <dc:creator>Ana Rodrigues</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4949655870</guid>
    </item>
    <item>
      <title><![CDATA[Sizzle Rizzle]]></title>
      <description><![CDATA[
          <video style="display: none" src="https://nerdy.dev/media/sizzle-reel-2025.mp4" alt="A quick peek into projects from my past" height="1080" width="1920">
        <p>A <small>small</small> sample of <strong>UI</strong>, <strong>code</strong>, <strong>tools</strong>, and <strong>design</strong>
from my 20 professional years of webdev.</p>
</video>]]></description>
      <pubDate>Fri, 04 Jul 2025 21:20:40 +0000</pubDate>
      <link>https://nerdy.dev/sizzle-rizzle?utm_source=rss</link>
      <dc:creator>Adam Argyle</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4872246908</guid>
    </item>
    <item>
      <title><![CDATA[WWW Ep212 With Dave Rupert]]></title>
      <description><![CDATA[
          <img style="display: none" src="https://nerdy.dev/media/www-ep212.jpg" alt="Dave Rupert shown as Macho Man Randy Standards" height="1400" width="1400">
        <p><span class="Tag">Ep #212</span><br>
<strong>TalkShop Show w/ Macho Man Randy Standards</strong></p>
<p><a href="https://robbiethewagner.dev">Robbie</a> and I chat with <a href="https://daverupert.com">Dave Rupert</a> about whiskey, web culture, the quirks of building side projects, the shifting landscape of the web, AI-driven development, spec-driven workflows, RSS’s decline, and more.</p>
<p>⤷ <a href="https://whiskey.fm/talkshop-show-w-macho-man-randy-standards">whiskey.fm</a> · <a href="https://www.youtube.com/watch?v=R15mWOB4MH0">youtube</a> · <a href="https://open.spotify.com/episode/5WsfiALuQ6hRU46StSBmJP">spotify</a> · <a href="https://podcasts.apple.com/us/podcast/talkshop-show-w-macho-man-randy-standards/id1552776603?i=1000729691108">apple</a></p>
]]></description>
      <pubDate>Thu, 02 Oct 2025 14:29:04 +0000</pubDate>
      <link>https://nerdy.dev/www-ep212-with-dave-rupert?utm_source=rss</link>
      <dc:creator>Adam Argyle</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4965882100</guid>
    </item>
    <item>
      <title><![CDATA[closedBy=any]]></title>
      <description><![CDATA[
          <img style="display: none" src="https://nerdy.dev/media/bzz.jpg" alt="5th element buzz off scene" height="414" width="944">
        <p>Quick post on <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy"><code>closedBy="any"</code></a>, a declarative way to add light-dismiss to a dialog:</p>
<pre><code class="language-html"><pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0"><code><span class="line"><span style="color:var(--shiki-foreground)">&lt;</span><span style="color:var(--shiki-token-string-expression)">dialog</span><span style="color:var(--shiki-token-function)"> closedBy</span><span style="color:var(--shiki-foreground)">=</span><span style="color:var(--shiki-token-string-expression)">"any"</span><span style="color:var(--shiki-foreground)">&gt;</span></span>
<span class="line"><span style="color:var(--shiki-foreground)">  &lt;</span><span style="color:var(--shiki-token-string-expression)">p</span><span style="color:var(--shiki-foreground)">&gt;Hi, I'm a dialog.&lt;/</span><span style="color:var(--shiki-token-string-expression)">p</span><span style="color:var(--shiki-foreground)">&gt;</span></span>
<span class="line"><span style="color:var(--shiki-foreground)">&lt;/</span><span style="color:var(--shiki-token-string-expression)">dialog</span><span style="color:var(--shiki-foreground)">&gt;</span></span></code></pre>
</code></pre>
<p>And just like that, tapping or clicking outside the dialog will close it.</p>

        <h2>
          Oh yeah! Well I bet I can't use it yet.
          <a name="oh-yeah!-well-i-bet-i-can't-use-it-yet." href="https://nerdy.dev/closedby-any?utm_source=rss#oh-yeah!-well-i-bet-i-can't-use-it-yet.">#</a>
        </h2>
       <p>Here's the browser support:</p>
<script type="module">
  import "https://cdn.jsdelivr.net/npm/baseline-status";
</script>

<p><baseline-status featureid="dialog-closedby"></baseline-status></p>
<p>Or a bit-o-js to get you started:</p>
<pre><code class="language-js"><pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0"><code><span class="line"><span style="color:var(--shiki-token-constant)">someDialog</span><span style="color:var(--shiki-token-function)">.addEventListener</span><span style="color:var(--shiki-foreground)">(</span><span style="color:var(--shiki-token-string-expression)">'click'</span><span style="color:var(--shiki-token-punctuation)">,</span><span style="color:var(--shiki-foreground)"> ({target:dialog}) </span><span style="color:var(--shiki-token-keyword)">=&gt;</span><span style="color:var(--shiki-foreground)"> {</span></span>
<span class="line"><span style="color:var(--shiki-token-keyword)">  if</span><span style="color:var(--shiki-foreground)"> (</span><span style="color:var(--shiki-token-constant)">dialog</span><span style="color:var(--shiki-foreground)">.nodeName </span><span style="color:var(--shiki-token-keyword)">===</span><span style="color:var(--shiki-token-string-expression)"> 'DIALOG'</span><span style="color:var(--shiki-foreground)">)</span></span>
<span class="line"><span style="color:var(--shiki-token-constant)">    dialog</span><span style="color:var(--shiki-token-function)">.close</span><span style="color:var(--shiki-foreground)">(</span><span style="color:var(--shiki-token-string-expression)">'dismiss'</span><span style="color:var(--shiki-foreground)">)</span></span>
<span class="line"><span style="color:var(--shiki-foreground)">})</span></span></code></pre>
</code></pre>

        <h2>
          More resources
          <a name="more-resources" href="https://nerdy.dev/closedby-any?utm_source=rss#more-resources">#</a>
        </h2>
       <p><em>You</em> should write one! </p>
<p>I'm writing this because I haven't seen enough folks using it or talking about it. Pretty nifty feature if ya ask me. So I'll make a 10m post.</p>
<p>If you want more <code>&lt;dialog&gt;</code> goodness, I wrote a fun post about how to make <strong>nice</strong> dialogs cuz the defaults are so poopy. Check it out and <a href="https://nerdy.dev/have-a-dialog">have a dialog</a>.</p>
<br>

<q class="notebook">

<p><a href="https://nerdy.dev/notebook/dialog-starter.html">Also checkout the <strong>Dialog Starter</strong> notebook!</a></p>
</q>]]></description>
      <pubDate>Thu, 16 Oct 2025 04:42:39 +0000</pubDate>
      <link>https://nerdy.dev/closedby-any?utm_source=rss</link>
      <dc:creator>Adam Argyle</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4981955759</guid>
    </item>
    <item>
      <title><![CDATA[The History of Themeable User Interfaces]]></title>
      <description><![CDATA[
<p><small>This post is an excerpt from our comprehensive online course, <a href="https://designtokenscourse.com/">Subatomic: The Complete Guide To Design Tokens</a>. The course digs into <em>everything</em> that goes into creating design token systems and themeable user interfaces to help <a href="https://bradfrost.com/blog/post/the-multi-all-the-things-organization/">Multi All-The-Things organizations</a> meet the multifarious needs of their digital products.</small></p>



<p><a href="https://designtokenscourse.com/">Design tokens</a> may be the latest incarnation, but software creators have been creating themeable user interfaces for quite a long time! As with all things, we can study history to learn from our past to inform our future. So let’s dig in!</p>



<h2 class="wp-block-heading">1970s: The first commercial GUIs</h2>



<p>The <a href="https://en.wikipedia.org/wiki/Graphical_user_interface#History">history</a> of the <a href="https://en.wikipedia.org/wiki/Graphical_user_interface">graphical user interface (GUI)</a> is fascinating and naturally involved a lot of research, iteration, and development before being unleashed upon the world. The first commercial computer featuring a GUI was the <a href="https://en.wikipedia.org/wiki/Xerox_Alto">Xerox Alto</a> in 1973. Behold it in all its glory:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="871" height="1024" src="https://bradfrost.com/wp-content/uploads/2025/05/image-871x1024.png" alt="Xerox Alto GUI" class="wp-image-24057" srcset="https://bradfrost.com/wp-content/uploads/2025/05/image-871x1024.png 871w, https://bradfrost.com/wp-content/uploads/2025/05/image-700x823.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/image-768x903.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/image-1307x1536.png 1307w, https://bradfrost.com/wp-content/uploads/2025/05/image-1742x2048.png 1742w" sizes="auto, (max-width: 871px) 100vw, 871px"></figure>



<p>GUIs ultimately hit the market in 1981 in the form of the <a href="https://en.wikipedia.org/wiki/Xerox_Star">Xerox Star</a>. The Star was similar to bands like <a href="https://en.wikipedia.org/wiki/Can_(band)">CAN</a> and <a href="https://en.wikipedia.org/wiki/Fugazi">Fugazi</a> in that sense that it wasn’t a commercial hit, but was massively influential to everything that followed.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="666" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.12.42 PM-1024x666.png" alt="Xerox Star GUI" class="wp-image-24054" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.12.42 PM-1024x666.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.12.42 PM-700x455.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.12.42 PM-768x499.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.12.42 PM-1536x999.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.12.42 PM-2048x1332.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p>The 70s also saw the rise of video games, especially with the runaway success of <a href="https://en.wikipedia.org/wiki/Pong">Pong</a>. <a href="https://en.wikipedia.org/wiki/Galaxian">Galaxian by Atari</a> was released in 1979 and was the first successful game that made use of a full-color RGB display.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="666" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.19 PM-1024x666.png" alt="Galaxian Atari gameplay" class="wp-image-24055" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.19 PM-1024x666.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.19 PM-700x455.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.19 PM-768x499.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.19 PM-1536x999.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.19 PM-2048x1332.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p>Game &amp; computer designers were contending with extremely limited processing/memory resources, so they were forced to get extremely creative in order to support full-color screens. <a href="https://en.wikipedia.org/wiki/Sprite_(computer_graphics)">Sprites</a> were used to manage the graphics and different states for UI elements, and color themes cleverly transformed the same shapes into different characters. Themed UI elements!</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="1293" height="1044" src="https://bradfrost.com/wp-content/uploads/2025/08/Sprite.png" alt="Sprite image from Galaxian" class="wp-image-24823" srcset="https://bradfrost.com/wp-content/uploads/2025/08/Sprite.png 1293w, https://bradfrost.com/wp-content/uploads/2025/08/Sprite-700x565.png 700w, https://bradfrost.com/wp-content/uploads/2025/08/Sprite-1024x827.png 1024w, https://bradfrost.com/wp-content/uploads/2025/08/Sprite-768x620.png 768w" sizes="auto, (max-width: 1293px) 100vw, 1293px"></figure>



<h2 class="wp-block-heading">1980s: Color, games, and the PC age</h2>



<p>The 1980s ushered in an era of full-color displays, which opened many new opportunities and challenges for designers. Just look at this glorious sprite showcasing from Nintendo’s 1985 era-defining video game, <a href="https://bradfrost.com/">Super Mario Brothers</a>:</p>



<figure class="wp-block-image size-large"><a href="https://www.spriters-resource.com/nes/supermariobros/asset/50365/"><img loading="lazy" decoding="async" width="1024" height="764" src="https://bradfrost.com/wp-content/uploads/2025/08/NES-Super-Mario-Bros.-Playable-Characters-Mario-Luigi-1-1024x764.png" alt="Mario and Luigi sprite sheet from Super Mario Bros" class="wp-image-24826" srcset="https://bradfrost.com/wp-content/uploads/2025/08/NES-Super-Mario-Bros.-Playable-Characters-Mario-Luigi-1-1024x764.png 1024w, https://bradfrost.com/wp-content/uploads/2025/08/NES-Super-Mario-Bros.-Playable-Characters-Mario-Luigi-1-700x523.png 700w, https://bradfrost.com/wp-content/uploads/2025/08/NES-Super-Mario-Bros.-Playable-Characters-Mario-Luigi-1-768x573.png 768w, https://bradfrost.com/wp-content/uploads/2025/08/NES-Super-Mario-Bros.-Playable-Characters-Mario-Luigi-1-1536x1147.png 1536w, https://bradfrost.com/wp-content/uploads/2025/08/NES-Super-Mario-Bros.-Playable-Characters-Mario-Luigi-1.png 1921w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a></figure>



<p><strong>It’s <em>wild</em> that two of the most iconic characters in the history of pop culture — red-clad Mario and green-clad Luigi — are themeable UI elements born from pragmatic ingenuity to overcome technological challenges</strong>. Freaking amazing.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="852" src="https://bradfrost.com/wp-content/uploads/2025/08/mario-and-luigi-design-system-1024x852.png" alt="8-bit Mario and Luigi with the caption &quot;two of the most iconic characters in the history of pop culture are themeable UI elements born from pragmatic ingenuity to overcome technological challenges.&quot;" class="wp-image-24882" srcset="https://bradfrost.com/wp-content/uploads/2025/08/mario-and-luigi-design-system-1024x852.png 1024w, https://bradfrost.com/wp-content/uploads/2025/08/mario-and-luigi-design-system-700x583.png 700w, https://bradfrost.com/wp-content/uploads/2025/08/mario-and-luigi-design-system-768x639.png 768w, https://bradfrost.com/wp-content/uploads/2025/08/mario-and-luigi-design-system.png 1200w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p><strong>This ingenious sprite theming is a masterclass in creative constraints.</strong> One thing you absolutely can’t unsee once you know is learning that <strong><a href="https://www.youtube.com/watch?v=ai7d1K4Yf6A">the clouds and the bushes in Super Mario Bros are the exact same shape, just themed differently!</a></strong></p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="576" src="https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.12.30-PM-1024x576.png" alt="" class="wp-image-24886" srcset="https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.12.30-PM-1024x576.png 1024w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.12.30-PM-700x394.png 700w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.12.30-PM-768x432.png 768w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.12.30-PM-1536x864.png 1536w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.12.30-PM.png 2038w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p>To put a finer point on this: the creators established the game’s structure and functionality and understood that <strong>duplicating all of that structure &amp; functionality merely to achieve different aesthetic results would be wasteful, expensive, and imprudent.</strong> <strong>So instead they created a themeable design system to <a href="https://bradfrost.com/blog/post/the-new-separation-of-concerns/">achieve this critical separation of concerns between structure/functionality &amp; aesthetics</a>.</strong> Incredible!</p>



<h3 class="wp-block-heading">The PC Era</h3>



<p>Naturally, full-color displays found their way into personal computer operating systems in the 1980s. Several <a href="https://www.youtube.com/watch?v=euhp8Vn2FTw">full-color computers</a> hit the market in the mid-80s, and the iconic <a href="https://en.wikipedia.org/wiki/Apple_II">Apple II</a> released its first full-color display in 1987.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="666" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.36 PM-1024x666.png" alt="" class="wp-image-24070" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.36 PM-1024x666.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.36 PM-700x455.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.36 PM-768x499.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.36 PM-1536x999.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.36 PM-2048x1332.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<h2 class="wp-block-heading">1990s: OS-level theming, The Web, and CSS</h2>



<p>Microsoft introduced <a href="https://imgur.com/gallery/every-windows-3-1-theme-SsVYqM1#3FNW1hn">color schemes</a> with the release of Windows 3.1 in 1992. Users (including a young me!) could go into their preferences, choose a theme for the Windows UI, and even customize the colors. Easily the best theme was named “<a href="https://blog.codinghorror.com/a-tribute-to-the-windows-31-hot-dog-stand-color-scheme/">Hotdog Stand</a>“:</p>



<figure class="wp-block-image size-large"><a href="https://blog.codinghorror.com/a-tribute-to-the-windows-31-hot-dog-stand-color-scheme/"><img loading="lazy" decoding="async" width="1024" height="666" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.42 PM-1024x666.png" alt="Hot Dog Stand Windows 3.1 theme" class="wp-image-24064" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.42 PM-1024x666.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.42 PM-700x455.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.42 PM-768x499.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.42 PM-1536x999.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.42 PM-2048x1332.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a></figure>



<p>This was<strong> one of the most sophisticated, large-scale theming implementations that truly introduced the concept of real user preference and customization to the masses</strong>. This UI customization and themeability became even more robust with the release of <a href="https://en.wikipedia.org/wiki/Windows_95">Windows 95</a>.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="666" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.44 PM-1024x666.png" alt="Windows 95 appearence theme displays" class="wp-image-24065" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.44 PM-1024x666.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.44 PM-700x455.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.44 PM-768x499.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.44 PM-1536x999.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.44 PM-2048x1332.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p>These new operating systems unlocked new opportunities for software like <a href="https://en.wikipedia.org/wiki/Winamp">Winamp</a> to give users the ability to create their own music player skins, truly putting the “personal” in “personal computer.”</p>



<figure class="wp-block-image size-large"><a href="https://skins.webamp.org/"><img loading="lazy" decoding="async" width="1024" height="577" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-5.55.01 PM-1024x577.png" alt="" class="wp-image-24072" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-5.55.01 PM-1024x577.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-5.55.01 PM-700x395.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-5.55.01 PM-768x433.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-5.55.01 PM-1536x866.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-5.55.01 PM-2048x1155.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a><figcaption class="wp-element-caption">A glorious collection of Winamp skins courtesy of the <a href="https://skins.webamp.org/">Winamp Skin Museum</a></figcaption></figure>



<p>Of course, the World Wide Web also exploded onto the scene in the 1990s. 1993 saw the release of <a href="https://en.wikipedia.org/wiki/NCSA_Mosaic">Mosaic</a>, the world’s first commercial internet browser, but it was really the introduction of <a href="https://en.wikipedia.org/wiki/Netscape_Navigator">Netscape Navigator</a> in late 1994 that made the web an absolute <a href="https://www.youtube.com/watch?v=95-yZ-31j9A">phenomenon</a>.</p>



<p>The browser opened a portal into a million worlds, with <strong>each website providing its own unique user experience and interface.</strong> It was during this era that I found myself with my best friend making Dragonball Z fan websites on <a href="https://en.wikipedia.org/wiki/GeoCities">Geocities</a>. I’ve been in love ever since.</p>



<p><small>Note: check out the brilliantly-curated and thoughtful <a href="https://www.webdesignmuseum.org/">Web Design Museum</a> for more web history!</small></p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="800" height="600" src="https://bradfrost.com/wp-content/uploads/2025/05/image-1.png" alt="A Dragonball Z Geocities fan website" class="wp-image-24069" srcset="https://bradfrost.com/wp-content/uploads/2025/05/image-1.png 800w, https://bradfrost.com/wp-content/uploads/2025/05/image-1-700x525.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/image-1-768x576.png 768w" sizes="auto, (max-width: 800px) 100vw, 800px"><figcaption class="wp-element-caption">An example of a Dragonball Z Geocities fan website. I wish I could find my own; I remember tiled background images, Goku and Vegeta animated GIFs, fire graphics, hit counter, and a guestbook</figcaption></figure>



<p>Accomplishing custom designs in the early web relied on hard coding styling information into the HTML using elements like <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/font"><code>&lt;font&gt;</code> tag</a>. Thankfully, <strong><a href="https://en.wikipedia.org/wiki/CSS">CSS</a> hit the scene in late 1996</strong>, which provided a dedicated language for styling and revolutionized how we create for the web.</p>



<h2 class="wp-block-heading">2000s: The Web Grows and Native Hits The Scene</h2>



<p>The web continued to take off and the technologies to create it continued to improve. Books like <a href="https://zeldman.com/">Jeffrey Zeldman</a>‘s <em><a href="https://en.wikipedia.org/wiki/Designing_with_Web_Standards">Designing with Web Standards</a></em> and CSS books by pioneers like <a href="https://en.wikipedia.org/wiki/Molly_Holzschlag">Molly Holzschlag</a> and <a href="https://meyerweb.com/eric/books/">Eric Meyer</a> helped the world’s emerging web community create beautiful and expressive web experiences.</p>



<p>In 2003, <a href="https://daveshea.com/">Dave Shea</a> created the <a href="https://csszengarden.com/">CSS Zen Garden</a>, which beautifully demonstrated the concept of <a href="https://en.wikipedia.org/wiki/Separation_of_concerns">separation of concerns</a> as it applies to the languages of the web.</p>



<figure class="wp-block-image size-large"><a href="https://csszengarden.com/"><img loading="lazy" decoding="async" width="1024" height="666" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.52 PM-1024x666.png" alt="" class="wp-image-24085" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.52 PM-1024x666.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.52 PM-700x455.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.52 PM-768x499.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.52 PM-1536x999.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-3.13.52 PM-2048x1332.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a></figure>



<p>Using the <a href="https://csszengarden.com/examples/index">exact same HTML file</a>, designers could use CSS to design radically different aesthetics. For me and countless others, the CSS Zen Garden drove home the importance of the <a href="https://bradfrost.com/blog/post/the-new-separation-of-concerns/">separation of concerns</a> and unlocked the real creative potential for the web as a design medium.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="615" src="https://bradfrost.com/wp-content/uploads/2025/05/css-zen-1-1024x615.png" alt="" class="wp-image-24088" srcset="https://bradfrost.com/wp-content/uploads/2025/05/css-zen-1-1024x615.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/css-zen-1-700x421.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/css-zen-1-768x462.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/css-zen-1-1536x923.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/css-zen-1-2048x1231.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p>The web field marched into the <a href="https://en.wikipedia.org/wiki/Web_2.0">Web 2.0 era</a> bursting with creative expression, which was put on full display on websites like <a href="https://en.wikipedia.org/wiki/Myspace">MySpace</a>.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="735" height="597" src="https://bradfrost.com/wp-content/uploads/2025/08/97771356db04bef1b0620a75f8794da0.jpg" alt="" class="wp-image-24839" srcset="https://bradfrost.com/wp-content/uploads/2025/08/97771356db04bef1b0620a75f8794da0.jpg 735w, https://bradfrost.com/wp-content/uploads/2025/08/97771356db04bef1b0620a75f8794da0-700x569.jpg 700w" sizes="auto, (max-width: 735px) 100vw, 735px"></figure>



<p>This customization started entering into more functional software, like then-new tools like Google Personalized Homepages (later renamed <a href="https://en.wikipedia.org/wiki/IGoogle">iGoogle</a>).</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="596" src="https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-27-at-9.58.41-PM-1024x596.png" alt="Google Personalized Homepage featuring themes like &quot;Beach&quot; &quot;Bus Stop&quot; &quot;City Scape&quot; &quot;Sweet Dreams&quot; &quot;Tea House&quot; and &quot;Seasonal Scape&quot;" class="wp-image-24840" srcset="https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-27-at-9.58.41-PM-1024x596.png 1024w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-27-at-9.58.41-PM-700x408.png 700w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-27-at-9.58.41-PM-768x447.png 768w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-27-at-9.58.41-PM-1536x894.png 1536w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-27-at-9.58.41-PM-2048x1193.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></figure>



<p>Up until this point, authoring CSS to achieve sophisticated theming was extremely laborious, hard-coded, and fraught. Adding to the complexity was that the whole industry was thrust into a brand-new mobile era as soon as <a href="https://www.youtube.com/watch?v=MnrJzXM7a6o">Steve Jobs muttered “and one more thing…” in 2007</a>.</p>



<h2 class="wp-block-heading">2010s: Sass, Design Systems, Design Tokens</h2>



<p><a href="https://andreipfeiffer.dev/blog/2022/scalable-css-evolution/part3-css-processors">CSS pre-processors like Sass and Less</a> hit the scene in the mid-2000s, and <a href="https://sass-lang.com/">Sass</a> 2.0 introduced this wild little idea called <a href="https://sass-lang.com/documentation/variables/">variables</a> in 2010. This was embraced by designers and developers who finally had a <a href="https://en.wikipedia.org/wiki/Don%27t_repeat_yourself">DRY</a> way of defining a design language.</p>



<pre class="wp-block-code"><code>$primary-color: #3498db;

button {
  background-color: $primary-color;
}</code></pre>



<p>This unlocked new opportunities for themeability and more dynamic styling on the web. These new capabilities coincided with the emergence of <a href="https://alistapart.com/article/responsive-web-design/">responsive web design</a>, modular CSS methodologies like <a href="https://github.com/stubbornella/oocss">OOCSS</a>, <a href="https://smacss.com/">SMACSS</a>, <a href="https://getbem.com/">BEM</a>, and others. In May of 2013 I introduced a thing called <a href="https://bradfrost.com/blog/post/atomic-web-design/">Atomic Design</a>. A few months later, <a href="https://react.dev/">React</a> was introduced. The zeitgeist was flexibility, modularity, and component-driven design/dev, which ultimately coalesced under the label “<strong>design systems.”</strong></p>



<p>Design systems like <a href="https://m3.material.io/">Google’s Material Design</a> emerged in 2014-2015 and had themeability in mind right out of the gate.</p>



<figure class="wp-block-image size-large"><a href="https://medium.com/@MartaKakozwa/how-to-choose-colors-for-material-design-app-b72da1ccbd9a"><img loading="lazy" decoding="async" width="1024" height="511" src="https://bradfrost.com/wp-content/uploads/2025/08/1_myRyhLXr5HtniWsxdM9-lw-1024x511.webp" alt="Material Design with theme switching" class="wp-image-24874" srcset="https://bradfrost.com/wp-content/uploads/2025/08/1_myRyhLXr5HtniWsxdM9-lw-1024x511.webp 1024w, https://bradfrost.com/wp-content/uploads/2025/08/1_myRyhLXr5HtniWsxdM9-lw-700x350.webp 700w, https://bradfrost.com/wp-content/uploads/2025/08/1_myRyhLXr5HtniWsxdM9-lw-768x384.webp 768w, https://bradfrost.com/wp-content/uploads/2025/08/1_myRyhLXr5HtniWsxdM9-lw-1536x767.webp 1536w, https://bradfrost.com/wp-content/uploads/2025/08/1_myRyhLXr5HtniWsxdM9-lw.webp 1902w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a></figure>



<p>These design systems demonstrated to the world how its possible to deliver a unified design language to multiple products, platforms, and businesses. Holy crap was it ambitious! Still is! In order to support these vast and multifarious product UIs, the concepts, technology, and tooling around theming needed to evolve.</p>



<h3 class="wp-block-heading">Design tokens origin story</h3>



<p>While Sass variables gave designers &amp; developers a great way to define and use a design language, <a href="https://www.jina.me/">Jina Anne</a> and Jon Levine at Salesforce <a href="https://youtu.be/wDBEc3dJJV8?si=jVV8JK68HsvvQOPh&amp;t=730">delivered a talk</a> that gave the concept of these low-level design decisions a more potent name: <strong>design tokens</strong>.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>Design tokens are the sub atoms — the smallest pieces — of the design system. They’re an abstraction of our UI visual design and store style properties as variables.</p><cite>Jina Anne</cite>
</blockquote>



<p>There are a <a href="https://atlassian.design/tokens/design-tokens">number</a> <a href="https://www.designtokens.org/glossary/">of</a> <a href="https://m3.material.io/foundations/design-tokens/overview">definitions</a> <a href="https://spectrum.adobe.com/page/design-tokens/">and</a> <a href="https://css-tricks.com/what-are-design-tokens/">explainers</a> <a href="https://piccalil.li/blog/what-are-design-tokens/">about</a> what design tokens are, which can be summarized like this. <strong>Design tokens are:</strong></p>



<ul class="wp-block-list">
<li><strong>The smallest (<a href="https://designtokenscourse.com/">subatomic</a>) elements of a design system</strong></li>



<li><strong>Design decisions for a design language</strong></li>



<li><strong>Design properties stored as variables</strong></li>



<li><strong>Implementation/technology agnostic source of truth</strong> that can be converted into any format</li>



<li><strong>A common language for design </strong>used to connect people, disciplines, tools, and systems</li>



<li><strong>The engine of themeable user interfaces</strong></li>
</ul>



<p>In the mid 2010, code tools like <a href="https://github.com/salesforce-ux/theo">Theo</a> from Salesforce and <a href="https://styledictionary.com/">Style Dictionary</a> from Amazon emerged to transform implementation-agnostic JSON or YAML token definitions into technology-specific formats like Sass variables, iOS &amp; Android formats, JS objects, and an important new native web technology called <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties">CSS custom properties</a>. </p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.18.55-PM-1024x572.png" alt="Diagram showing how tokens are defined in JSON and then converted into tech-specific formats" class="wp-image-24890" srcset="https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.18.55-PM-1024x572.png 1024w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.18.55-PM-700x391.png 700w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.18.55-PM-768x429.png 768w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.18.55-PM-1536x857.png 1536w, https://bradfrost.com/wp-content/uploads/2025/08/Screenshot-2025-08-28-at-1.18.55-PM-2048x1143.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"><figcaption class="wp-element-caption"><a href="https://designtokenscourse.com/">Our Subatomic design tokens course</a> introduces core concepts for creating token systems, provides sample architecture, and detailed walkthroughs for how to set up a token system in Figma &amp; code then successfully adopt it in your org’s product ecosystem.</figcaption></figure>



<p>CSS custom properties give the web theming superpowers — and to do it live! Seriously, check out this amazing website by <a href="https://abandon.ie/">Abban Dunne</a>:</p>



<figure class="wp-block-video"><video controls="" src="https://bradfrost.com/wp-content/uploads/2025/08/abban-2.mp4"></video></figure>



<p>There’s a whole lot to design tokens! And a whole lot of opportunity  Perhaps that’s why a <a href="https://www.w3.org/community/design-tokens/">Design Tokens W3C Community Group</a> was established in 2019 to help wrangle and standardize some of the structure, naming, and architecture around design tokens.</p>



<h2 class="wp-block-heading">2020s: Multi-All-The-Things, AI, and beyond</h2>



<h3 class="wp-block-heading">Tokens in Figma</h3>



<p>Achieving elegant theming in design tools like Adobe Photoshop/Illustrator/XD, Sketch, and Figma has been elusive until very recently. Styles existed in tools like Sketch and Figma, but were insufficient for the level of customization digital orgs had to account for. In 2022, <a href="https://tokens.studio/">Tokens Studio</a> was released to help bring more sophisticated theming to Figma, and in the summer of 2023, Figma released <a href="https://help.figma.com/hc/en-us/articles/15339657135383-Guide-to-variables-in-Figma">Figma Variables</a>, a tool-native way to define and wield design tokens. This created the opportunity to design UIs that can more easily support multiple themes. The magic trick is really cool!</p>



<figure class="wp-block-video"><video controls="" src="https://bradfrost.com/wp-content/uploads/2025/08/ice-cream-theme-switch-figma-1.mp4"></video></figure>



<h3 class="wp-block-heading">The rise of AI and themeability on demand</h3>



<p>Of course, at the time of this writing, generative AI exploded onto the scene and is introducing a brand-new paradigm that will influence the world, including how to support themeable user interfaces. These tools are still emerging, but already we’ve found many ways AI tooling can help in the creation and adoption of design token systems (we get into all of it in <a href="https://designtokenscourse.com/">our course</a>!). It’s also easy to imagine that thes new technologies can help usher in a new era of hyper-personalized user experiences. My user experience may look and behave wildly differently than yours, which introduces all sorts of fascinating opportunities and challenges.</p>



<h3 class="wp-block-heading">Our Multi-All-The-Things Reality</h3>



<p>It’s been a hell of a journey that’s gotten us here, but we are here. We are alive in a moment where we’re responsible for designing and building for a <a href="https://bradfrost.com/blog/post/the-multi-all-the-things-organization/">Multi All-The-Things</a> reality. </p>



<figure class="wp-block-image size-large"><a href="https://bradfrost.com/blog/post/the-multi-all-the-things-organization/"><img loading="lazy" decoding="async" width="1024" height="575" src="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-9.52.15 PM-1024x575.png" alt="Multi-all-the-things: products, brands, frameworks, platforms, redesigns, rebrands, product families, color modes, subbrands, whitelabeling, campaigns" class="wp-image-24110" srcset="https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-9.52.15 PM-1024x575.png 1024w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-9.52.15 PM-700x393.png 700w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-9.52.15 PM-768x431.png 768w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-9.52.15 PM-1536x863.png 1536w, https://bradfrost.com/wp-content/uploads/2025/05/Screenshot-2025-05-12-at-9.52.15 PM-2048x1150.png 2048w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a></figure>



<p>We have more websites, apps, screens, flows, products, ecosystems, technologies, and paradigms than at any other moment in human history. And it doesn’t show any sign of slowing down anytime soon. </p>



<p>No one can predict the future, but <strong>I strongly feel that organizations that have sturdy foundations, infrastructure, and systems in place will be in a better place to navigate whatever the future may bring.</strong></p>



<h2 class="wp-block-heading">Consider checking out our design tokens course</h2>



<p><strong>There’s SO MUCH technology, architecture, tooling, cross-disciplinary collaboration, orchestration, and gold old-fashioned human processes involved in creating robust, successful, and themeable UI systems.</strong> Achieving balance in these systems is truly an art form: constrained-yet-expressive, systematic-yet-extensible, considered-yet-customizable. </p>



<p>We’ve devoted the last 12 years of our lives to helping teams establish these systems, and spent 6 months distilling all of these concepts, best practices, hard-earned lessons into a comprehensive online course called <a href="https://designtokenscourse.com/">Subatomic: The Complete Guide To Design Tokens</a>. </p>



<figure class="wp-block-image size-large"><a href="https://designtokenscourse.com/"><img loading="lazy" decoding="async" width="1024" height="576" src="https://bradfrost.com/wp-content/uploads/2025/07/subatomic-social-card-1024x576.jpg" alt="Subatomic: The Complete Guide To Design Tokens" class="wp-image-24522" srcset="https://bradfrost.com/wp-content/uploads/2025/07/subatomic-social-card-1024x576.jpg 1024w, https://bradfrost.com/wp-content/uploads/2025/07/subatomic-social-card-700x394.jpg 700w, https://bradfrost.com/wp-content/uploads/2025/07/subatomic-social-card-768x432.jpg 768w, https://bradfrost.com/wp-content/uploads/2025/07/subatomic-social-card.jpg 1200w" sizes="auto, (max-width: 1024px) 100vw, 1024px"></a></figure>



<p>Our course covers everything your team needs to create &amp; maintain a successful themeable design systems at your organization. <a href="https://designtokenscourse.com/#order">Order our course</a> and you’ll get:</p>



<ul class="wp-block-list">
<li>Over 13 hours of in-depth video </li>



<li>Figma &amp; code sample architecture</li>



<li>Naming &amp; governance workflows</li>



<li>PDF slides with over 150 resources</li>



<li>Access to our Slack community</li>



<li>Certificate of completion</li>



<li>Free updates</li>
</ul>



<p>WHEW! I suppose this post is a history lesson slash infomercial for our course. But walking through this history, I’m struck by the fact that my personal journey on this Earth coincides with much of the history of themeable user interfaces. I am so incredibly grateful that I’m on this planet during such a time of technological innovation and convergence between technology, design, and art. I’ve now spent literally half of my life professionally designing cool-looking things for the World Wide Web. And I think that’s pretty badass.</p>
]]></description>
      <pubDate>Thu, 28 Aug 2025 17:46:10 +0000</pubDate>
      <link>https://bradfrost.com/blog/post/the-history-of-themeable-user-interfaces/</link>
      <dc:creator>Brad Frost</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4927477746</guid>
    </item>
    <item>
      <title><![CDATA[A custom --light-dark() function in CSS that works with any type of value (not just colors!) in just 3 LOC]]></title>
      <description><![CDATA[<p><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2025/09/custom-light-dark-with-color-scheme-bramus.png" alt="" width="560" height="413" class="alignnone size-medium wp-image-35730"></p>
<div class="intro">
<p>In <a href="https://brm.us/at-function-and-if">CSS <code>@function</code> + CSS <code>if()</code> = 🤯</a> I explored combining CSS custom functions and CSS <code>if()</code>, leading to the creation of a custom <code>--light-dark()</code> CSS function that works with any value <em>(not just colors!)</em>.</p>
<p>The built function relied on a style query that query a custom property <code>--scheme</code>, which itself was set to mirror the value of the <code>color-scheme</code> property.</p>
<p>Thanks to the recently resolved <code>color-scheme()</code> function, this indirection is no longer needed, and the code can become much, much simpler.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note note--warning">
<p><strong>⚠️ This post is about an upcoming CSS feature. You can’t use it … yet.</strong></p>
<p>While custom functions with <code>@function</code> and CSS <code>if()</code> are available in Chrome <em>(and only Chrome, for now)</em>, the mentioned <code>color-scheme()</code> only exists on paper right now.</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="https://www.bram.us/2025/09/30/css-custom-light-dark/#the-code" name="the-code">#</a> The Code</h3>
<p>If you are here just for the code, here it is:</p>
<pre><code class="language-css" style="tab-size: 2">@function --light-dark(--l, --d) {
  result: if(color-scheme(dark): var(--d); else: var(--l));
}</code></pre>
<p>Usage is similar to the built-in <code>light-dark()</code>, but difference is that it can be used with <em>any</em> type of value:</p>
<pre><code class="language-css" style="tab-size: 2">#element {
  color-scheme: light dark;
  color: light-dark(#333, #e4e4e4);
  background-image: --light-dark(url(light.svg), url(dark.svg));
}</code></pre>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="https://www.bram.us/2025/09/30/css-custom-light-dark/#color-scheme-function" name="color-scheme-function">#</a> The <code>color-scheme()</code> function</h3>
<p>Powering this custom <code>--light-dark()</code> function is the new <code>color-scheme()</code> function. It’s a new addition to CSS which <a href="https://github.com/w3c/csswg-drafts/issues/10577#issuecomment-3329616811">we only recently resolved on adding</a> with the CSS Working Group.</p>
<p>The <code>color-scheme()</code> function allows you to query the <em>used</em> color scheme of an element. The function can be used in both <code>@container</code> queries and [the new <code>if()</code>](https://developer.chrome.com/blog/if-article).</p>
<p>In the following example I am using the function directly on an element <em>(instead of placing it into a custom function)</em>:</p>
<pre><code class="language-css" style="tab-size: 2">#element {
  color-scheme: light dark;
  background-image: if(color-scheme(dark): url(dark.svg); else: url(light.svg));
}</code></pre>
<p>The reason we need this new <code>color-scheme()</code> <em>function</em> is because the <code>color-scheme</code> <em>property</em> only lists which color schemes are supported. When set to <code>light dark</code>, you are indicating the element can adapt its rendering to either a light or dark version. This is controlled by your light/dark setting, with the resulting used value being one of those listed values.</p>
<p>FYI: You can also force a component into a specific mode by listing only 1 value for <code>color-scheme</code>, e.g. <code>color-scheme: light</code> will force the component into <code>light</code> mode, regardless of your OS setting.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<h3><a href="https://www.bram.us/2025/09/30/css-custom-light-dark/#spread-the-word" name="spread-the-word">#</a> Spread the word</h3>
<p>Feel free to reshare one of the following posts on social media to help spread the word:</p>
<ul>
<li><a href="https://bsky.app/profile/bram.us/post/3m23dckbyuk2x">🦋 BlueSky</a></li>
<li><a href="https://front-end.social/@bramus/115295054674239673">🦣 Mastodon</a></li>
</ul>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on BlueSky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></description>
      <pubDate>Tue, 30 Sep 2025 20:03:56 +0000</pubDate>
      <link>https://www.bram.us/2025/09/30/css-custom-light-dark/</link>
      <dc:creator>Bram.us</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4963567963</guid>
    </item>
    <item>
      <title><![CDATA[The CSS Podcast is back! And I’m a co-host now.]]></title>
      <description><![CDATA[<p><img loading="lazy" decoding="async" src="https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-560x560.jpg" alt="" width="560" height="560" class="alignnone size-medium wp-image-35757" srcset="https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-560x560.jpg 560w, https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-1120x1120.jpg 1120w, https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-150x150.jpg 150w, https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-768x768.jpg 768w, https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-1536x1536.jpg 1536w, https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic-1568x1568.jpg 1568w, https://www.bram.us/wordpress/wp-content/uploads/2025/10/TCP-Libsyn-Profile_Generic.jpg 1984w" sizes="auto, (max-width: 560px) 100vw, 560px"></p>
<p><a href="https://thecsspodcast.libsyn.com/">The CSS Podcast</a> is back! Together with <a href="https://una.im/">Una</a> I’m co-hosting season 5 of the show, and we have some good episodes coming your way! The first episode of the season – episode 092 of the full show – got published and is about <a href="https://thecsspodcast.libsyn.com/92-css-if-and-custom-functions">CSS <code>if()</code> and CSS Custom Functions (<code>@function</code>)</a>.</p>
<blockquote><p>Welcome back to the new season of the CSS Podcast, where Una and Bramus are your guides, your cohosts, and your CSS best friends.</p>
<p>In this episode we dig into two very powerful new CSS features: inline conditionals with the <code>if()</code> function, and custom functions.</p></blockquote>
<p>It’s a bit of a bittersweet thing to do, because Una used to run the show together with <a href="https://nerdy.dev/">Adam</a> who <a href="https://www.bram.us/2025/04/14/anti-climax/">got laid off back in April</a>. Thankfully the three of us got to meet in person at <a href="https://cssday.nl/">CSS Day</a> back in June, where Adam gave us his blessing to continue the show.</p>
<p><a href="https://thecsspodcast.libsyn.com/">The CSS Podcast →</a><br>
<a href="https://thecsspodcast.libsyn.com/92-css-if-and-custom-functions">The CSS Podcast Episode 092 – →</a></p>
]]></description>
      <pubDate>Tue, 14 Oct 2025 09:18:58 +0000</pubDate>
      <link>https://www.bram.us/2025/10/14/the-css-podcast-is-back-and-im-a-co-host-now/</link>
      <dc:creator>Bram.us</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4979581155</guid>
    </item>
    <item>
      <title><![CDATA[Solved by CSS Scroll State Queries: hide a header when scrolling down, show it again when scrolling up.]]></title>
      <description><![CDATA[<figure><div style="width: 640px;" class="wp-video"><video class="wp-video-shortcode" id="video-35790-5" width="640" height="412" loop="" autoplay="" muted="" preload="metadata" controls="controls"><source type="video/mp4" src="https://www.bram.us/wordpress/wp-content/uploads/2025/10/scroll-state-scrolled.mp4?_=5"><a href="https://www.bram.us/wordpress/wp-content/uploads/2025/10/scroll-state-scrolled.mp4">https://www.bram.us/wordpress/wp-content/uploads/2025/10/scroll-state-scrolled.mp4</a></video></div><figcaption>Recording of <a href="https://codepen.io/bramus/pen/qEboVXG?editors=1100">the demo</a>, recorded in Chrome Canary.</figcaption></figure>
<div class="intro">
<p>There’s a new type of CSS scroll-state query coming: <code>scrolled</code></p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>Unlike <a href="https://developer.chrome.com/blog/css-scroll-state-queries#scrollable">the <code>scrollable</code> scroll-state queries</a>, <code>scrolled</code> remembers the last direction you scrolled into, which you can use to build “hidey bars”: when scrolling down (or having scrolled down), the hidey bar hides itself. When then scrolling back up, the hidey bar reveals itself.</p>
<p>Here’s the code that is needed to hide a fixed header when scrolling down:</p>
<pre><code class="lang-css">html {
  container-type: scroll-state;
}

header {
  transition: translate 0.25s;
  translate: 0 0;

  /* Slide header up when last having scrolled towards the bottom */
  @container scroll-state(scrolled: bottom) {
    translate: 0 -100%;
  }
}</code></pre>
<div class="update">
<p>Good suggestion <a href="https://front-end.social/@meduz@m.nintendojo.fr/115418937883458955">by meduz on Mastodon</a>, in case the header contains some interactive content that you can focus:</p>
<blockquote>
<p>Use <code>header:not(:focus-within)</code> to avoid hiding the bar if there’s focus in it.</p>
</blockquote>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<p>Below is a live demo using the code above. You can try it out yourself in Chrome Canary with the experimental Web Platform Features Flag enabled. If you browser does not support <code>scrolled</code> scroll-state queries, the header will remain fixed in place – a nice progressive enhancement if you’d ask me 🙂</p>
<p class="codepen" data-height="640" data-default-tab="result" data-slug-hash="qEboVXG" data-pen-title="Hidey Bar Demo (Hide on Scroll Down, Show on Scroll Up // Scroll State Queries)" data-user="bramus" style="height: 640px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/bramus/pen/qEboVXG"><br>
  Hidey Bar Demo (Hide on Scroll Down, Show on Scroll Up // Scroll State Queries)</a> by Bramus (<a href="https://codepen.io/bramus">@bramus</a>)<br>
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<p><script async="" src="https://public.codepenassets.com/embed/index.js"></script></p>
<p>The feature is expected to ship to Chrome Stable in Chrome 144.</p>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
<p>If that demo looks familiar: I featured it here on bram.us before, as a demo to <a href="http://brm.us/hidey-bar">use scroll-driven animations to track and remember the scroll direction</a>. Thanks to <code>scrolled</code> scroll-state queries, that hack is no longer needed 🙂</p>
</div>
<p style="text-align: center; font-size: 28px; font-family: 'times new roman', times; margin: 3em 0;">~</p>
<div class="note">
	<p><b>🔥 Like what you see? Want to stay in the loop? Here's how:</b></p>
	<ul>
            <li><a href="https://bsky.app/profile/bram.us">🦋 Follow @bram.us on Bluesky</a></li>
            <li><a href="https://bram.us/feed">🔸 Follow bram.us using RSS</a></li>
	</ul>
	<p>I can also be found on <a href="https://x.com/bramus">𝕏 Twitter</a> and <a href="https://front-end.social/@bramus">🐘 Mastodon</a> but only post there sporadically.</p>
</div>
]]></description>
      <pubDate>Wed, 22 Oct 2025 17:03:05 +0000</pubDate>
      <link>https://www.bram.us/2025/10/22/solved-by-css-scroll-state-queries-hide-a-header-when-scrolling-down-show-it-again-when-scrolling-up/</link>
      <dc:creator>Bram.us</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4989179648</guid>
    </item>
    <item>
      <title><![CDATA[Paxos accidentally mints more than twice the global GDP in PayPal stablecoins]]></title>
      <description><![CDATA[
        <img src="https://primary-cdn.web3isgoinggreat.com/entryImages/logos/resized/paxos_300.webp" alt="A group of yellow, green, and blue semi-translucent overlapping blobs with a circle cut out of the middle, followed by &quot;Paxos&quot; in grey capitals" width="300px">
        <p>Paxos, the issuer of PayPal's PYUSD stablecoin, accidentally minted 300 trillion of the supposedly dollar-pegged token. For context, this is approximately 2.5x the global GDP, and around 125x the total number of US dollars actually in circulation.</p><p>Paxos later announced that the mint was an "internal technical error", and that they had burned the excess tokens.</p><p>While PayPal promises its customers that "Reserves are held 100% in US dollar deposits, US treasuries and cash equivalents – meaning that customer funds are available for 1:1 redemption with Paxos," there clearly isn't much in the way of safeguards to ensure that is always the case. As with most stablecoin issuers, Paxos merely issues self-reported and unreviewed portfolio reports, and monthly third-party attestations (not audits) of reserves.</p><p></p>
        <ul>
          <li>
            <a href="https://x.com/whale_alert/status/1978539763301744815">
              Tweet by Whale Alert
            </a> 
          </li>
          <li>
            <a href="https://x.com/Paxos/status/1978565015943950411">
              Tweet by Paxos
            </a> 
          </li>
        </ul>
      ]]></description>
      <pubDate>Wed, 15 Oct 2025 21:35:35 +0000</pubDate>
      <link>https://web3isgoinggreat.com/single/paxos-accidental-mint</link>
      <dc:creator>Web3 is Going Just Great</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4981657971</guid>
    </item>
    <item>
      <title><![CDATA[Linked: In the Future All Food Will Be Cooked in a Microwave, and if You Can’t Deal With That Then You Need to Get Out of the Kitchen]]></title>
      <description><![CDATA[
    <blockquote>
<p><span style="font-size: 19.227997px; -webkit-text-size-adjust: 100%;">One of my chefs mentioned that if they could cook the steak on the grill they could get it right the first time. This is not an acceptable attitude in the microwave era. Chefs have fragile egos and they all seem to enjoy cooking (???) so it’s obvious they’re just too attached to the food. Also they’re worried I’m planning on firing all of them. That’s true but not relevant here.</span></p>
</blockquote>
    <p><a aria-label="Permalink to bookmark" rel="bookmark" title="Permalink to bookmark" href="https://calebhearth.com/linked/a405d33c-cace-4a9d-8700-af878d419b92">⬣</a></p>
  ]]></description>
      <pubDate>Wed, 24 Sep 2025 17:52:33 +0000</pubDate>
      <link>https://www.colincornaby.me/2025/08/in-the-future-all-food-will-be-cooked-in-a-microwave-and-if-you-cant-deal-with-that-then-you-need-to-get-out-of-the-kitchen/</link>
      <dc:creator>Hearthside by Caleb Hearth</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4956600748</guid>
    </item>
    <item>
      <title><![CDATA[Who needs a flying car when you have display: grid]]></title>
      <description><![CDATA[<p>A friend asked me if I could build him a simple website for his new Mechanical Engineering business: just a few pages showing off what they’re about and what they can do.&nbsp;</p>
<p>This is the sort of work I did when I was just starting out with web dev - marketing sites. It’s been 14 years since I worked a gig like this, and I was so struck by how much easier everything is now.&nbsp;</p>
<p>First of all, the capabilities of CSS are absolutely magical. I needed to <a href="https://css-for-js.dev/">do a course</a> to catch up on my knowledge and feel confident again but once I did… putting together layouts is a dream.&nbsp;</p>
<p>At one point my friend asked if I could change all the places where they had yellow to blue… back in the day this would have been annoying but all I had to do was update a single custom property! I made multiple Netlify Deploy previews for him to look at with different shades of blue and it all took me under 10 minutes. </p>
<p>And because of improvements to CSS and the native platform you need so much less JavaScript to achieve specific layouts and functionality. When you do need JS, you can just use the regular JavaScript API, no need for third party libraries like jQuery or MooTools (lol).</p>
<p>Look, I am old and I am comparing this to the Dark Ages when we were still on CSS 2, but I just can't move past how much better and easier everything is!</p>
<p>At the same time I’m cognisant of the fact this isn’t really a job anymore. My friend had no interest in messing with a Squarespace and was willing to pay my hourly rate to build a static site because he knew he could trust me to do a good job. Most clients probably feel differently, and would prefer to either choose these low or no-code tools to make the websites themselves, or employ devs to build bloated React projects.&nbsp;My own day job is building an incredibly complex Web Application. </p>
<p>What a shame that is, as the technology has never been better for developers to build lean, accessible and attractive websites.</p>

      <hr>
      <p>Thanks for reading this post via RSS! Let me know your thoughts by leaving a comment on the <a href="https://rachsmith.com/who-needs-a-flying-car">original post</a> or send <a href="mailto:contact@rachsmith.com?subject=re%3A%20Who%20needs%20a%20flying%20car%20when%20you%20have%20display%3A%20grid">me an email</a>.</p>
      ]]></description>
      <pubDate>Fri, 10 Oct 2025 00:45:37 +0000</pubDate>
      <link>https://rachsmith.com/who-needs-a-flying-car/</link>
      <dc:creator>Rach Smith&#39;s digital garden</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4974854457</guid>
    </item>
    <item>
      <title><![CDATA[Reblogging this here.

A lot of folks on this and other platforms ask if I’m still working on…]]></title>
      <description><![CDATA[<p><a class="tumblr_blog" href="https://jakewyattonline.tumblr.com/post/794690780648390656/a-necropolis-layout-from-last-year-i-still-work" target="_blank">jakewyattonline</a>:</p><blockquote><div class="npf_row"><figure class="tmblr-full" data-orig-height="1680" data-orig-width="2000"><img src="https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s640x960/4b8f03097c85a46c9d7adf3cdcc973be887d8a52.png" data-orig-height="1680" data-orig-width="2000" srcset="https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s75x75_c1/41e3370391a785854b6214c0d00f01f739904e1f.png 75w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s100x200/e6fc6d15407b9bfd44dd17a1271a5eef05678c04.png 100w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s250x400/68a1b9318ece689c9158a2cc22effaa686544d48.png 250w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s400x600/7c7c9e4e0a1a06661fb50d7247e2ade9f783e939.png 400w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s500x750/37227259572ce8b6b40ec20c7cf5b1b6a0e94634.png 500w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s540x810/9c23e5de91504ba27e27111018582083edc19798.png 540w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s640x960/4b8f03097c85a46c9d7adf3cdcc973be887d8a52.png 640w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s1280x1920/0dae04ca113d90c0c34a446c1dbaca9164555f60.png 1280w, https://64.media.tumblr.com/b974ae41d918daa333d29e3f3c4f8d48/feb3e486168aee98-40/s2048x3072/00867e5e89f2b39b3a1ed3dbd3d535e24335651a.png 2000w" sizes="(max-width: 1280px) 100vw, 1280px"></figure></div><p>A Necropolis layout from last year. I still work on Necropolis here and there, but anytime I start to pick up the thread, to find my way, the job pulls me out again. The struggle of making television has demanded almost all of my time since July 2020.</p><p><br></p><p>Getting to make cartoons with so many talented people has been a huge, all-consuming privilege. It’s taken me five years on the job just to get my legs under me, just to <i>begin</i> to feel that I know what I’m doing. It’s taken everything I have just to keep my head above water, to get <i>something</i> resembling our crew’s talent and effort and intention onto the screen. For that entire half-decade I have longed to make comics, to write stories for myself, to draw for myself. But there was always something that needed doing. </p><p><br></p><p>Recently I’ve realized that denying that part of myself, telling it to wait its turn through one more script revision, one more review, one more retake, one more episode, one more season–it’s taken a toll that I can no longer afford to pay. I don’t think I can keep going unless I take some time to write and draw and explore outside of the studio production system. As we fight through post on the third season of My Adventures With Superman and start writing the first season of Lantern, I’m looking for ways to work and lead that will get me some of that time back–and my teams are really coming through for me.</p><p><br></p><p>But even if I can find that time, I still need to decide how to spend it, and where to put the things I make. I need a place away from the urgency and deadlines and pressure of the studio. Tumblr has always had the best community, is where I’ve always been the most comfortable, so I’m gonna start here. I will cross-post some of what I do here to other, more hellish parts of the internet, but this is going to be home for a while.</p><p><br></p><p>I hope that those of you who never left don’t mind me coming back. I hope that those of you who’ve nested here in the meantime don’t mind some new-type Oldtype showing up. I just want a place to be myself, instead of another dusty piece of production equipment.</p><p>Thanks,</p><p>Jake</p></blockquote>
<p>Reblogging this here.<br><br>A lot of folks on this and other platforms ask if I’m still working on Necropolis. I am (see above), but <a href="https://www.tumblr.com/necropoliscomic/624470192557375489/necropolis-chapter-three-page-twenty-six-notice?source=share" target="_blank">what started as a short-term, long-shot tv development project</a> has become four seasons of <a href="https://www.youtube.com/watch?v=H8zqDd4bdmE&amp;ab_channel=AdultSwim" target="_blank">television</a>, now spread across two series and five years of my life.</p><p>I have been entirely subsumed by the job. But I’m trying to reconstitute myself.</p><p>I haven’t and won’t quit Necropolis, but before I can fully reengage it, I have to find my way back to making things that aren’t studio television. Once that’s done, I need to determine the correct angle of approach for Necropolis, specifically. And all that is just going to take time.</p><p>If you still care, thank you so much. Sincerely. I hope I’m able to continue (and eventually finish!) this story while you still do. I really like making this comic for you all. I would really like to see it done.</p><p>-Jake</p>]]></description>
      <pubDate>Sun, 14 Sep 2025 19:24:25 +0000</pubDate>
      <link>https://necropoliscomic.tumblr.com/post/794692540241772544</link>
      <dc:creator>Necropolis</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4945192753</guid>
    </item>
    <item>
      <title><![CDATA[Junior Dev Tip: "Scroll Up"]]></title>
      <description><![CDATA[<p>My junior developer just came to me asking for help because their linting setup was broken. I’m writing this very quickly because I want to get all the details down.</p>
<blockquote>
<p>Junior Dev: “My linting is broken, can you help?”<br>
Me: “Sure”<br>
<em>get on a call with junior dev<br>
junior dev runs linting command which ends with lots of “command failed” errors as well as a line that says “17 warnings, 2 errors”</em><br>
Junior Dev: “See?”<br>
Me: “Scroll up.”<br>
<em>junior dev scrolls up and sees 2 errors that say they forgot an attribute</em><br>
Junior Dev: “Oh.”</p>
</blockquote>
<p>I’ve had other experiences recently as well where a different junior developer also couldn’t tell me what was wrong with something. The error was right in front of them and they just didn’t scroll to the right place. They would click everywhere except for the thing that would tell them where the error was.  I recognize that as a junior developers, you’re not as familiar with the tools, but the tools do provide you with information most of the time. You genuinely just need to take a few extra seconds and read what it is saying.</p>
<p><em>Note: I did ask my junior developer if it was ok to share this story. they said yes.</em></p>]]></description>
      <pubDate>Wed, 08 Oct 2025 16:28:48 +0000</pubDate>
      <link>https://alex.party/posts/2025-10-08-junior-dev-tip-scroll-up/</link>
      <dc:creator>Alex.Party</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4973408530</guid>
    </item>
    <item>
      <title><![CDATA[Somewhere Between Lost and Found]]></title>
      <description><![CDATA[<p>A few years ago, I received news of a professor’s passing in the most bizarrely impersonal way we tend to receive news these days: via a twitter mention from a college ex I hadn’t spoken to in years. The memorial service was set for that weekend in Central Massachusetts where he and his family lived. At the time, I was living in Cambridge, which was just a short train ride away. But I never made it past Back Bay.</p>]]></description>
      <pubDate>Thu, 24 Jul 2025 13:23:09 +0000</pubDate>
      <link>https://shortdiv.com/posts/somewhere-between-lost-and-found/</link>
      <dc:creator>&lt;shortdiv /&gt;</dc:creator>
      <guid isPermaLink="false">https://feedbin.me/entries/4890398226</guid>
    </item>
  </channel>
</rss>
