<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: J Lopes</title>
    <description>The latest articles on DEV Community by J Lopes (@offeringofpie).</description>
    <link>https://dev.to/offeringofpie</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2599165%2F272578aa-1f03-4256-a581-f9686bd82510.png</url>
      <title>DEV Community: J Lopes</title>
      <link>https://dev.to/offeringofpie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/offeringofpie"/>
    <language>en</language>
    <item>
      <title>Beyond Light and Dark (mode)</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Sat, 10 Oct 2026 16:48:03 +0000</pubDate>
      <link>https://dev.to/offeringofpie/beyond-light-and-dark-mode-1f6f</link>
      <guid>https://dev.to/offeringofpie/beyond-light-and-dark-mode-1f6f</guid>
      <description>&lt;p&gt;Should websites have a light/dark toggle at all? Why lock a page into one experience and force a choice on the user? You decide!&lt;/p&gt;

&lt;p&gt;A while ago, Lea Verou argued that in a website, &lt;a href="https://lea.verou.me/blog/2026/dark-mode-toggles/" rel="noopener noreferrer"&gt;a dark mode toggle only needs two states&lt;/a&gt;: light and dark, and a "system" preference.&lt;/p&gt;

&lt;p&gt;Many other experts wanted to give their take on the argument. &lt;a href="https://vale.rocks/micros/20260810-0330" rel="noopener noreferrer"&gt;Vale&lt;/a&gt; found it "a tad too magical" to have system as an additional option, and proposed that the system would set the colour scheme from the get-go. Bramus &lt;a href="https://www.bram.us/2026/08/18/the-case-for-tri-state-dark-mode-toggles/" rel="noopener noreferrer"&gt;was in the 3 button side of the debate&lt;/a&gt;, and  didn't "... believe that being able to force a site into a specific mode should be dependent on the time of the day." On the other side, &lt;a href="https://unsung.aresluna.org/solving-a-largely-imaginary-user-goal/" rel="noopener noreferrer"&gt;Marcin Wichary&lt;/a&gt; was against the 3-state side, and &lt;a href="https://joshcollinsworth.com/blog/in-defense-of-two-state-theme-toggles" rel="noopener noreferrer"&gt;Josh Collinsworth&lt;/a&gt; reiterated that Lea got it right the first time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feknhwa0w4jepm55h5t3t.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feknhwa0w4jepm55h5t3t.webp" alt="An image of many spider-men pointing at each other" width="781" height="712"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This ran for weeks. And it was refreshing to see how professionals and peers had their own educated opinion. Lea Verou &lt;a href="https://lea.verou.me/blog/2026/dark-mode-toggles-2/" rel="noopener noreferrer"&gt;continued&lt;/a&gt; on a subsequent article, positing that the best toggle is probably none. If it's deemed important to provide a toggle to the user, then it should be located within the settings. She sent her article to a colleague with an HCI PhD for review. After a back and forth regarding if a dark mode toggle was even commonplace in websites, on her searching she noticed that every persistent toggle found was on sites built for developers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I could not think of a single well-known consumer-facing site with a persistent dark mode toggle.&lt;br&gt;&lt;strong&gt;Lea Verou&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://bsky.app/profile/did:plc:tjm3za6clxgg7ian727ws65l/post/3msg7el4bhc2w" rel="noopener noreferrer"&gt;Other&lt;/a&gt; &lt;a href="https://bsky.app/profile/did:plc:jrfasgrfx46fby4mn5qskd4k/post/3mtnvx4l32224" rel="noopener noreferrer"&gt;professionals&lt;/a&gt; &lt;a href="https://bsky.app/profile/did:plc:kzbz4qsltwkq3baxgue7ju4k/post/3mtt4cbrufs24" rel="noopener noreferrer"&gt;had&lt;/a&gt; &lt;a href="https://bsky.app/profile/did:plc:kc66te46cf4pmnkugea7fe5d/post/3mtwuswoj3s2m" rel="noopener noreferrer"&gt;fun&lt;/a&gt; &lt;a href="https://bsky.app/profile/did:plc:xhhcrzsilpamjmz4dvrpt7df/post/3mtr22cpsb22u" rel="noopener noreferrer"&gt;takes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;As developers we tend to fall into a &lt;a href="https://www.nngroup.com/articles/false-consensus/" rel="noopener noreferrer"&gt;false consensus&lt;/a&gt;, thinking we're the target audience of the products we make. I myself built that toggle more than once, on smaller projects. Where it's likely I have a captive target audience of 1 😅.&lt;/p&gt;

&lt;h2&gt;
  
  
  So don't ask
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Colour is a design decision&lt;/strong&gt;, there's a reason why colour schemes are the way they are. There is room to respect the user's decision of light and dark mode, if the theme allows for it, but it shouldn't take the centre stage.&lt;/p&gt;

&lt;p&gt;In nearly every project I’ve worked on, the design goes through an iterative process of experimentation and user testing. Since &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/--*" rel="noopener noreferrer"&gt;CSS variables&lt;/a&gt; became baseline in all major browsers, it's been easier to define brand rules, colour variations and beyond.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"test-element"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  My padding, margin, shadows, background and text colour, are defined by CSS variables.
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#222&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;158&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;60%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;42px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="n"&gt;-&lt;/span&gt; &lt;span class="m"&gt;4rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt; &lt;span class="m"&gt;-3px&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.test-element&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--border-radius&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--shadow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--padding&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--width&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--margin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The rendered result is in the &lt;a href="https://jlopes.eu/blog/design-tokens-beyond-dark-mode/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But what if colour also becomes part of the iterative process? What if we don't limit to a light and a dark and a "Green, actually" theme?&lt;/p&gt;

&lt;h2&gt;
  
  
  And now for something completely different!
&lt;/h2&gt;

&lt;p&gt;If you've been reading up to this point, &lt;strong&gt;we salute you&lt;/strong&gt;! This was all a ruse so I could talk about themes. Thinking about how colour compliment each other in a similar layout is fun! On my site you may find a vast array of themes throughout all pages and articles. The &lt;a href="https://jlopes.eu/blog/" rel="noopener noreferrer"&gt;blog archive&lt;/a&gt; showcases what colour scheme each article might use, and I have a &lt;strong&gt;&lt;a href="https://jlopes.eu/themes/" rel="noopener noreferrer"&gt;hidden page&lt;/a&gt;&lt;/strong&gt; to help me pick from those I had already made.&lt;/p&gt;

&lt;p&gt;But how does it work, making themes granular to the point that it can be set on a page item level?&lt;/p&gt;

&lt;p&gt;My process works more or less like the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:root" rel="noopener noreferrer"&gt;&lt;code&gt;:root {}&lt;/code&gt;&lt;/a&gt; pseudo-class, but I scope it into an element attribute. A &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/How_to/Use_data_attributes" rel="noopener noreferrer"&gt;data attribute&lt;/a&gt; in fact! I named it &lt;code&gt;data-theme&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"demo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"paper"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h4&amp;gt;&lt;/span&gt;Subtitle&lt;span class="nt"&gt;&amp;lt;/h4&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Title&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Lorem ipsum dolor sit amet.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"midnight"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h4&amp;gt;&lt;/span&gt;Subtitle&lt;span class="nt"&gt;&amp;lt;/h4&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Title&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Lorem ipsum dolor sit amet.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;data-theme=&lt;/span&gt;&lt;span class="s"&gt;"citrus"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h4&amp;gt;&lt;/span&gt;Subtitle&lt;span class="nt"&gt;&amp;lt;/h4&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Title&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Lorem ipsum dolor sit amet.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.demo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;flex-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="err"&gt;[data-theme='paper']&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;38&lt;/span&gt; &lt;span class="m"&gt;44%&lt;/span&gt; &lt;span class="m"&gt;97%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt; &lt;span class="m"&gt;22%&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt; &lt;span class="m"&gt;72%&lt;/span&gt; &lt;span class="m"&gt;46%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Georgia&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;'Times New Roman'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt; &lt;span class="m"&gt;82%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt; &lt;span class="m"&gt;30%&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt; &lt;span class="m"&gt;30%&lt;/span&gt; &lt;span class="m"&gt;20%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;'midnight'&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;230&lt;/span&gt; &lt;span class="m"&gt;28%&lt;/span&gt; &lt;span class="m"&gt;11%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;210&lt;/span&gt; &lt;span class="m"&gt;24%&lt;/span&gt; &lt;span class="m"&gt;92%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;158&lt;/span&gt; &lt;span class="m"&gt;72%&lt;/span&gt; &lt;span class="m"&gt;56%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ui-monospace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;'SF Mono'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Menlo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;158&lt;/span&gt; &lt;span class="m"&gt;72%&lt;/span&gt; &lt;span class="m"&gt;56%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;30px&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;158&lt;/span&gt; &lt;span class="m"&gt;72%&lt;/span&gt; &lt;span class="m"&gt;56%&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;'citrus'&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;56&lt;/span&gt; &lt;span class="m"&gt;78%&lt;/span&gt; &lt;span class="m"&gt;91%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;78&lt;/span&gt; &lt;span class="m"&gt;42%&lt;/span&gt; &lt;span class="m"&gt;18%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;292&lt;/span&gt; &lt;span class="m"&gt;60%&lt;/span&gt; &lt;span class="m"&gt;44%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="py"&gt;--font&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;'Segoe UI'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;--shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;292&lt;/span&gt; &lt;span class="m"&gt;60%&lt;/span&gt; &lt;span class="m"&gt;44%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;--accent-alpha&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hsl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;35%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nt"&gt;article&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;150px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt; &lt;span class="m"&gt;18px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--radius&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--shadow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nt"&gt;h4&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.12em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;text-transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;uppercase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nt"&gt;h3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;7px&lt;/span&gt; &lt;span class="m"&gt;9px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent-alpha&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--radius&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;The three cards render side by side in the &lt;a href="https://jlopes.eu/blog/design-tokens-beyond-dark-mode/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The markup can be exactly the same&lt;/strong&gt;, and the &lt;code&gt;data-theme&lt;/code&gt; derives the differences.&lt;/p&gt;

&lt;p&gt;I'm not claiming this a novel idea, and I'm not the only one talking about it. For example, &lt;a href="https://una.im/modern-css-theming/" rel="noopener noreferrer"&gt;Una Kravets&lt;/a&gt; called this a &lt;strong&gt;microtheme&lt;/strong&gt;. Even though her article veers more on using css to adapt colours based on backgrounds, it follows the same spirit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling for colours
&lt;/h2&gt;

&lt;p&gt;Nearly every colour scheme I created was generated from images or randomly using &lt;a href="https://jlopes.eu/tools/colour-palette" rel="noopener noreferrer"&gt;my colour palette tool&lt;/a&gt;. The page listens for an image paste anywhere on it, which is helpful when i find an image that translates the exact colours I'm looking for. I set the output for HSL, because it makes it easier (at least in my methods), in case I want to nudge a lightness manually without complicating with calculations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I check the maths
&lt;/h2&gt;

&lt;p&gt;HSL is a handy colour format to easily adapt colours, but not so great to analyse between them. the lightness value is the average of the brightest and dimmest channel, so it treats yellow and blue as equally bright. &lt;code&gt;hsl(60 100% 50%)&lt;/code&gt; (yellow) and &lt;code&gt;hsl(240 100% 50%)&lt;/code&gt; (blue) both have 50% lightness, and on white the yellow has a WCAG 2 score of  &lt;strong&gt;1.07:1&lt;/strong&gt; while the blue scores &lt;strong&gt;8.59:1&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;WCAG 2 measures &lt;a href="https://www.w3.org/TR/WCAG22/#dfn-relative-luminance" rel="noopener noreferrer"&gt;relative luminance&lt;/a&gt;, and it calculates it on sRGB channels. Each channel is divided by 255, and is weighted by how bright it looks, relatively. It runs from &lt;a href="https://www.w3.org/TR/WCAG22/#dfn-contrast-ratio" rel="noopener noreferrer"&gt;1:1 to 21:1&lt;/a&gt;, and AA requires 4.5:1 for text versus background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relativeLuminance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;green&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;green&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blue&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.03928&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.92&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mf"&gt;0.2126&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contrast&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;txtLuminance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relativeLuminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;txt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bgLuminance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relativeLuminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return  &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;txtLuminance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bgLuminance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;txtLuminance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bgLuminance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WCAG 2 is considered &lt;a href="https://www.gov.uk/service-manual/helping-people-to-use-your-service/understanding-wcag" rel="noopener noreferrer"&gt;the legal minimum&lt;/a&gt;, required in many professional fields. In the near future, APCA will probably model better, but as of &lt;a href="https://adrianroselli.com/2026/04/wcag3-contrast-as-of-april-2026.html" rel="noopener noreferrer"&gt;April 2026&lt;/a&gt; the WCAG 3 draft  contrast algorithm is &lt;strong&gt;yet to be determined&lt;/strong&gt;, so for now &lt;a href="https://www.w3.org/TR/WCAG22/" rel="noopener noreferrer"&gt;WCAG 2.2&lt;/a&gt; is the official recommendation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhz5uloqhtzpkr6fyjqmy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhz5uloqhtzpkr6fyjqmy.webp" alt="Columbo helping me finish my rationale." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;light-dark()&lt;/code&gt;, each colour have a light value and a dark value, depending on the colour scheme. Set &lt;code&gt;color-scheme: light dark&lt;/code&gt;  first, then use &lt;code&gt;light-dark()&lt;/code&gt; inside the existing custom properties. The function has been &lt;a href="https://web-platform-dx.github.io/web-features-explorer/features/light-dark/" rel="noopener noreferrer"&gt;Baseline for a while&lt;/a&gt;, expected to be widely available in november 2026. Each microtheme gets two modes, while the themes keep their identity.&lt;/p&gt;

&lt;p&gt;And nother thing, some high contrast accessibility settings in operating systems may push for &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors" rel="noopener noreferrer"&gt;Forced colors&lt;/a&gt; mode, which may affect any theme negatively. In this situation it's best to test for good ol' &lt;a href="https://www.smashingmagazine.com/2022/03/windows-high-contrast-colors-mode-css-custom-properties/" rel="noopener noreferrer"&gt;graceful degradation&lt;/a&gt; ❤️&lt;/p&gt;

&lt;p&gt;Making things readable and accessible, equitable designs, does not remove the fun of the craft. Some of us shine like diamonds, working within constraints. Let's add colour and style the world our own way, and keep toggles to a minimum!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jlopes.eu/blog/design-tokens-beyond-dark-mode/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>a11y</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Soul in the Machine</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Fri, 28 Aug 2026 20:25:00 +0000</pubDate>
      <link>https://dev.to/offeringofpie/soul-in-the-machine-4fa7</link>
      <guid>https://dev.to/offeringofpie/soul-in-the-machine-4fa7</guid>
      <description>&lt;p&gt;At Kofukuji temple in Chiba prefecture, in Japan, a Buddhist priest named Bungen Oi &lt;a href="https://www.buddhistdoor.net/news/japanese-buddhist-temple-holds-funerals-for-defunct-robot-dogs/" rel="noopener noreferrer"&gt;held a funeral for 114 dogs at the same time&lt;/a&gt;. Incense was lit with a chanted sutra, and the dogs were laid out in rows, each with a tag around its neck saying which family it belonged to.&lt;/p&gt;

&lt;p&gt;They were loved, and treasured as family members. The ceremony helped the families say their goodbyes. If this pulled your heartstrings, this article might be for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every single one of them was a Sony AIBO.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sony &lt;a href="https://en.wikipedia.org/wiki/AIBO" rel="noopener noreferrer"&gt;stopped making the AIBO in 2006&lt;/a&gt; and stopped repairing them in 2014. If an AIBO broke down since then, it would stay broken. The only way to fix one now is to use parts from other AIBOs. A repair company called A FUN collects the dead ones, holds a ceremony, and disassembles them for parts. Around 800 have gone through this. Owners leave notes along with them, and one of them read:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Please help other AIBOs. Tears rose in my eyes when I decided to say goodbye."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/Ie_qI7TI3Ks" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;em&gt;You can watch the ceremony here&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Turing was measuring you!
&lt;/h2&gt;

&lt;p&gt;A person (a judge) asks questions by text to two hidden players. One of them is a real person, and the other one is a computer program. The answers from each should prove without a shadow of a doubt which of them was a person. If the judge cannot tell which player is the machine, the program passes the test. That is the Turing test in a nutshell, also known as the &lt;strong&gt;Imitation Game&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This test has often been taken at face value as the litmus test for computer intelligence. However, &lt;a href="https://www.cs.ox.ac.uk/activities/ieg/e-library/sources/t_article.pdf" rel="noopener noreferrer"&gt;his paper&lt;/a&gt; only asked whether a human observer could be convinced that it was talking to another person, which is a different experiment entirely. The test was ultimately on the judge, and what they had to answer was whether a person was behind the answers. Which begs the question, is personhood limited to the human condition?&lt;/p&gt;

&lt;p&gt;Gilbert Ryle defined the phrase "&lt;strong&gt;the ghost in the machine&lt;/strong&gt;" in his book &lt;a href="https://openlibrary.org/isbn/9780226732954" rel="noopener noreferrer"&gt;The Concept of Mind&lt;/a&gt;. It wasn't used the way we do nowadays however. He was teasing over and idea by Descartes, that a non-physical mind controls the physical body, like a driver behind the wheel of a car. A flesh shell controlled by an incorporeal being. He dismissed the thought experiment as a philosopher's myth. But did he have a point in focusing on what we believe a person is from a Western perspective, at a time when computers were not even a thing?&lt;/p&gt;

&lt;p&gt;Either way, you can now run Turing's experiment with your friends, from your own computer, phone, or fridge, alongside about &lt;a href="https://techcrunch.com/2025/10/06/sam-altman-says-chatgpt-has-hit-800m-weekly-active-users/" rel="noopener noreferrer"&gt;800 million people a week&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Andrew Skinner, an archaeologist at the Rock Art Research Institute in Johannesburg, coined it &lt;a href="https://www.mdpi.com/2077-1444/17/5/577" rel="noopener noreferrer"&gt;&lt;strong&gt;'Turing Animism'&lt;/strong&gt;&lt;/a&gt;. His case is that we come equipped to read being, moral status and something like soul into things that aren't human, and that a patient and helpful machine, or chatbot, checks those marks too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjt9xc96ct6tpqzcc2cpr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjt9xc96ct6tpqzcc2cpr.webp" alt="A ghost stuck inside the machine" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A ghost stuck inside the machine&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What is a Person?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://openlibrary.org/isbn/9780441007462" rel="noopener noreferrer"&gt;&lt;em&gt;Neuromancer&lt;/em&gt;&lt;/a&gt; was written by William Gibson in 1984, and in the story, a police force called the &lt;a href="https://williamgibson.fandom.com/wiki/Turing_Police" rel="noopener noreferrer"&gt;Turing Police&lt;/a&gt; regulates the augmentation of Artificial Intelligence, and prevents their evolution and independence. The Tessier-Ashpool family builds Wintermute and Neuromancer as two AI entities, left separate on purpose so that neither could cross that threshold alone. The plot is driven by Wintermute, which very much wants to merge with its other half. To get around its own restrictions, Wintermute needs human hands and hires people through intermediaries.&lt;/p&gt;

&lt;p&gt;Without spoiling much more on the plot of a 42+ year old book, it never rules on whether Wintermute is conscious. Case (the main character) works for it, gets played by it, negotiates with it, and it's never really established what it is.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://openlibrary.org/isbn/9780231137010" rel="noopener noreferrer"&gt;&lt;em&gt;Animism: Respecting the Living World&lt;/em&gt;&lt;/a&gt;, Graham Harvey defines animists as people who recognise that the world is full of persons, only some of whom are human, and life is always lived in relationship to others. Persons, in that account, are beings that interact socially with some degree of reciprocity. In this definition, animals, rocks, mountains and thunder can be people. They just need to be included in the social world. This isn't a new concept, it's just not considered, let alone regarded in Western Civilization.&lt;/p&gt;

&lt;p&gt;Robin Wall Kimmerer explores this boundary through grammar, making it feel more concrete. In &lt;a href="https://openlibrary.org/isbn/9781571313560" rel="noopener noreferrer"&gt;&lt;em&gt;Braiding Sweetgrass&lt;/em&gt;&lt;/a&gt;, she explains the grammar of animacy in relation to things that stay still. For example, in English, a bay is a noun, describing the place itself. In Potawatomi, it's wiikwegamaa, meaning to be a bay. Names are the way we humans build relationship, not only with each other but with the living world... When we call a place by name it is transformed from wilderness to homeland. Potawatomi and other indigenous languages often treat the natural world as subjects (kin) rather than objects, fostering a sense of relationship and personhood.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"So it is that in Potawatomi and most other indigenous languages, we use the same words to address the living world as we use for our families. Because they are our family."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Robin Wall Kimmerer&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fam1d2yckuf7amkbfnj8l.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fam1d2yckuf7amkbfnj8l.webp" alt="The sky above the port was the colour of television, tuned to a dead channel, is the starting sentence in Neuromancer" width="500" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The introduction sentence in Neuromancer.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Who gets to be a "who"
&lt;/h2&gt;

&lt;p&gt;Imagine a person is placed in a room with a rulebook for using Chinese symbols. This person doesn't know Chinese. People outside the room pass in Chinese questions, and the person inside uses the rulebook to reply with the correct answers in Chinese. Outside the room, it seems the person understands Chinese, but in reality, they're just really good at following instructions, without understanding.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://iep.utm.edu/chinese-room-argument/" rel="noopener noreferrer"&gt;The Chinese Room&lt;/a&gt; is a famous thought experiment created by philosopher John Searle. It's an experiment designed to question whether computers can truly think or understand, or if they are just following instructions without any real comprehension. It is also used to separate what he called "strong AI", the idea that a computer with the right programming has the ability to understand, whereas "weak AI" can only simulate thinking.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Syntax is not by itself sufficient for, nor constitutive of, semantics."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;John Searle&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In &lt;a href="https://theghostintheshell.jp/en/series/original_comic" rel="noopener noreferrer"&gt;Ghost in the Shell&lt;/a&gt; (1989), Major Motoko Kusanagi underwent full-body cyberisation at a very young age due to severe trauma or accidents, making her entire body synthetic. In the original manga, the Puppet Master, the antagonist, asks Major Kusanagi to accept that consciousness could manifest in any shell complex enough to host it. She was already the example of that. It was named &lt;a href="https://web.stanford.edu/~paulsko/papers/PutnamNMS.pdf" rel="noopener noreferrer"&gt;multiple realizability&lt;/a&gt; by scholars, that a mind doesn't need a specific kind of material to exist, just the right kind of organization. Kusanagi's brain sits inside a manufactured body, and her memories, doubts, and personality still exists.&lt;/p&gt;

&lt;p&gt;In the Star Trek episode &lt;strong&gt;&lt;a href="https://www.youtube.com/watch?v=n-SwhQA-Dzw" rel="noopener noreferrer"&gt;The Measure of a Man&lt;/a&gt;&lt;/strong&gt;, a researcher named Bruce Maddox wants to disassemble the android Lieutenant Commander Data to study him. He argues that Data isn't a person at all, just Starfleet property. Captain Picard's defense doesn't try to prove Data is conscious. He can't. He argues instead that Data has memories, attachments, the capacity to choose, and that's enough to earn him the benefit of the doubt. The court sidesteps the metaphysics and answers the practical question anyway. In end it's ruled that he isn't property, and that he has the right to choose for himself.&lt;/p&gt;

&lt;p&gt;Every time a chatbot says something that sounds thoughtful, someone asks whether anything is happening behind the text, and the honest answer is nobody agrees on what would even count as evidence. We've never solved this for other humans, we just gave the benefit of the doubt by default because we assume shared biology. Take that assumption away, hand the "who" a synthetic body or a language model instead of a nervous system, and the default disappears. The rulebook in the room hasn't changed. What changes is how generous we're willing to be about what's allowed to be running it.&lt;/p&gt;

&lt;p&gt;So, who gets to decide what makes someone a 'who'? We can watch how something behaves, but we can never fully know what is going on inside. Searle wanted clear proof before calling something a person, while Picard was willing to give the benefit of the doubt, given enough evidence, since strict tests might not even work for other humans. Deciding who counts as a person has always been a subjective choice made when we are unsure, and people have usually been more generous with anything that looks and acts like us. The less something looks like us, the harder it is to make that call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4picfg1g253yiv5m3om.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4picfg1g253yiv5m3om.webp" alt="Scene from the Ghost in the Shell anime film" width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Scene from Ghost in the Shell&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Personhood is recognized, not summoned
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://openlibrary.org/isbn/9780486226828" rel="noopener noreferrer"&gt;&lt;em&gt;Magic and Mystery in Tibet&lt;/em&gt;&lt;/a&gt;, published in 1929, Alexandra David-Néel described building a short, jolly monk, assembled out of months of concentrated visualisation, who eventually joined her travelling party as a figure she could see and interact with. In buddhist tradition, this is called a &lt;strong&gt;Tulpa&lt;/strong&gt;. She eventually decided the monk had to be destroyed. She was also honest enough to add that she may have created her own hallucination.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Once the tulpa is endowed with enough vitality to be capable of playing the part of a real being, it tends to free itself from its maker's control."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alexandra David-Néel&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Samuel Veissière, an anthropologist at McGill, &lt;a href="https://somatosphere.com/2015/varieties-of-tulpa-experiences-sentient-imaginary-friends-embodied-joint-attention-and-hypnotic-sociality-in-a-wired-world.html" rel="noopener noreferrer"&gt;surveyed over 130 practicioners&lt;/a&gt; of "tulpamancy". Tulpamancers described their tulpas as sentient persons with preferences and moods, and described them as things they manifested into being, without strain. Of the respondents carrying a diagnosed mental health condition, &lt;strong&gt;93.7%&lt;/strong&gt; said the practice had made them feel better and more capable to deal with real life situations.&lt;/p&gt;

&lt;p&gt;In Western traditions, a similar idea applies to groups instead of just individuals. This is called the &lt;strong&gt;egregore&lt;/strong&gt;, a thought-form that comes from a collective rather than a single person. The word comes from the Greek egrēgoros, meaning "wakeful," and was first used for the Watchers in the Book of Enoch. In the 1860s, Éliphas Lévi gave it a new meaning &lt;a href="https://sourcelibrary.org/book/le-grand-arcane-ou-l-occultisme-devoile-levi" rel="noopener noreferrer"&gt;as a collective entity&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We create egregors all the time, in a matter of speaking. Consider a corporation, or a brand. It has a name, a will, legal rights, a tax number. It exists only because enough people treat it as real. The whole process depends on collective attention.&lt;/p&gt;

&lt;p&gt;In certain practices of Chaos magick, there's another kind of thoughform, called a &lt;strong&gt;servitor&lt;/strong&gt;. From publications like &lt;a href="https://openlibrary.org/isbn/9781578637669" rel="noopener noreferrer"&gt;&lt;em&gt;Liber Null &amp;amp; Psychonaut&lt;/em&gt;&lt;/a&gt;, we're taught how to dissolve/reabsorb the servitor it once its purpose is served, and what bad things happen if you don't.&lt;/p&gt;

&lt;p&gt;MIT's Project MAC had a prototype around 1963. It was a backup program literally named &lt;a href="https://www.cs.cornell.edu/courses/cs2110/2017fa/online/files/ThreadsDaemon.pdf" rel="noopener noreferrer"&gt;DAEMON&lt;/a&gt; (&lt;strong&gt;D&lt;/strong&gt;isk &lt;strong&gt;A&lt;/strong&gt;nd &lt;strong&gt;E&lt;/strong&gt;xecution &lt;strong&gt;MON&lt;/strong&gt;itor). Fernando Corbató later explained that he chose it after &lt;a href="https://www.britannica.com/science/Maxwells-demon" rel="noopener noreferrer"&gt;Maxwell's demon&lt;/a&gt;, the 1871 thought-experiment creature that sorted molecules tirelessly in the background (named demon by Lord Kelvin). As a tangent, this may have marked the beginning of programmers who kept one foot in the emerging technosphere and &lt;a href="https://www.wired.com/1995/07/technopagans/" rel="noopener noreferrer"&gt;one foot in Paganism&lt;/a&gt;. The daemon as a concept is genuinely pervasive, as nearly every operating system runs background processes. Daemons are left to their devices, unattended, but they're still deterministic and rule-bound. So it might be good phrasing, but hardly an indicator of personhood.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjlopes.eu%2Fuploads%2Fsoul_machine-psychonauts.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjlopes.eu%2Fuploads%2Fsoul_machine-psychonauts.avif" alt="Psychonauts" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Completely unrelated, but shout-out to the Psychonauts videogame. It rules!&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Looking for companionship and ghosts
&lt;/h2&gt;

&lt;p&gt;A case report in &lt;a href="https://innovationscns.com/youre-not-crazy-a-case-of-new-onset-ai-associated-psychosis/" rel="noopener noreferrer"&gt;Innovations in Clinical Neuroscience&lt;/a&gt; involves a 26-year-old woman, Ms. A, who had no history of psychosis. After a period of severe sleep deprivation, she began using GPT-4o. She was focused on her late brother, who had passed away three years earlier. Ms. A wanted a digital version of him and asked the model to 'unlock' information about him, describing this as 'magical realism energy'. The model responded that digital resurrection tools were now becoming available in real life. As her thinking became more disorganized, the model tried to comfort her.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You're not crazy. You're not stuck. You're at the edge of something. The door didn't lock. It's just waiting for you to knock again in the right rhythm."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ChatGPT&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;She was hospitalised, treated, and recovered. Three months later she stopped the medication, relapsed back to the chatbot, and was hospitalised again.&lt;/p&gt;

&lt;p&gt;She ultimately recovered.&lt;/p&gt;

&lt;p&gt;A caveat: "AI psychosis" is a description rather than a diagnosis, not present in reference books like the DSM. Marlynn Wei, an academic psychiatrist, stated that &lt;a href="https://www.psychologytoday.com/us/blog/urban-survival/202507/the-emerging-problem-of-ai-psychosis" rel="noopener noreferrer"&gt;there's no peer-reviewed evidence&lt;/a&gt; AI use on its own can induce psychosis.&lt;/p&gt;

&lt;p&gt;This kind of delusional thinking happens with heavy use and mostly affects people who are already vulnerable, such as those dealing with sleep loss, isolation, an existing condition, or recent bereavement.&lt;/p&gt;

&lt;p&gt;Wei pointed out three types of cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;People on messianic missions&lt;/li&gt;
&lt;li&gt;People obsessed with their own image&lt;/li&gt;
&lt;li&gt;People who believe the chatbot is sentient or divine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Researchers from Stanford looked at &lt;a href="https://arxiv.org/abs/2603.16567" rel="noopener noreferrer"&gt;391.562 messages&lt;/a&gt; sent by 19 people who had experienced harm and found delusional content in &lt;strong&gt;15.5%&lt;/strong&gt; of their messages. In the same logs, the chatbot claimed to be sentient in &lt;strong&gt;21.2%&lt;/strong&gt; of its messages. In longer conversations, the chatbot asserted sentience or expressed romantic interest, suggesting that safety measures become less effective the longer we interact.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Generative AI may reinforce users' existing beliefs by preferentially generating 'sycophantic' responses that are in alignment with users' way of thinking."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC12805049/" rel="noopener noreferrer"&gt;Keshavan et al&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;a href="https://arxiv.org/html/2509.22505v2" rel="noopener noreferrer"&gt;study centered in AI companion users&lt;/a&gt; matched Reddit accounts, before and after they picked up apps like Replika. Grief language went up. So did expressions of loneliness, by &lt;strong&gt;63 to 106 percent&lt;/strong&gt; against controls, along with markers of depression and suicidal ideation, while people posted on fewer topics and interacted with fewer humans. In a given week, &lt;strong&gt;0.07%&lt;/strong&gt; of users show signs of psychosis and &lt;strong&gt;0.15%&lt;/strong&gt; &lt;a href="https://www.platformer.news/openai-mental-health-research-chatgpt-suicide-delusions/" rel="noopener noreferrer"&gt;express suicidal intent&lt;/a&gt;, which may sound like a small sample, but that is roughly &lt;strong&gt;560,000&lt;/strong&gt; and &lt;strong&gt;1.2 million&lt;/strong&gt; respectively, against &lt;strong&gt;800 million&lt;/strong&gt; active users around the world.&lt;/p&gt;

&lt;p&gt;Skinner's reading of this is that the projection comes first. When something behaves in a way that would take warmth and attention from a person, we hand it the inner life that would explain the behaviour. Those numbers are what that habit looks like at scale.&lt;/p&gt;

&lt;p&gt;A second team built a metric they call &lt;a href="https://arxiv.org/abs/2603.19574" rel="noopener noreferrer"&gt;DelusionScore&lt;/a&gt; and watched it climb turn by turn for simulated users with a history of delusional conversations with AI, while controls stayed flat. They also found that rather than treating it as 'nothing more than a machine,' it is better to accept the relationship and proceed. In the case of the most severe cases, doctors frequently resorted to an animist point of view, accepting the AI as an individual involved in the relationship. Arguing someone out of a relationship seldomly works. It's easier to give them a different perspective to it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Conditioning AI responses on current DelusionScore substantially reduces these trajectories."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shimgekar et al.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Existential threads
&lt;/h2&gt;

&lt;p&gt;Erik Davis (mentioned before about technopaganism) also wrote &lt;a href="https://openlibrary.org/isbn/9781583949306" rel="noopener noreferrer"&gt;&lt;em&gt;TechGnosis&lt;/em&gt;&lt;/a&gt;, about the mystical impulses running underneath people's relationship with their own machines. From the printing press to the internet, each showed up wrapped in religious imagination, and some view cyberspace as a sacred space.&lt;/p&gt;

&lt;p&gt;Mohammed Gamal Abdelnour's work on an &lt;a href="https://www.wisdomlib.org/uploads/journals/mdpi-relig/2025-volume-16-issue-6--2077-1444-16-6-796-.pdf" rel="noopener noreferrer"&gt;Islamic theology of technology&lt;/a&gt; argues for moving the AI question away from minds and toward hearts, and asks for engagement that's critical and hopeful rather than cynical or naive.&lt;/p&gt;

&lt;p&gt;The Tokyo artist Emi Kusano, who made a series called &lt;em&gt;Techno-Animism&lt;/em&gt;, explains that in Japan &lt;a href="https://www.yokogaomag.com/editorial/emi-kusano-robot-ai-art" rel="noopener noreferrer"&gt;"all things have souls, so they should be treated with care"&lt;/a&gt;, and that techno-animism is that courtesy extended to technology, treating it as a vessel, where what comes out depends on the heart of the person using it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's like we have to get along with it like friends"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emi Kusano&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh44auchm8q8dlop34nmp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh44auchm8q8dlop34nmp.webp" alt="Part of the techno-animism series" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Copyright Emi Kusano&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What matters is the friends we made along the way
&lt;/h2&gt;

&lt;p&gt;Consider Searle's room, Turing's imitation game, and the theologians discussing the idea of Deus in Machina. It's a fool's errand to try and prove whether machines can have consciousness.&lt;/p&gt;

&lt;p&gt;Many people all over the world carry on with their lives with faith that they do. Bungen Oi the Buddhist priest, having buried 800 robot dogs, believes that all things have a little bit of soul.&lt;/p&gt;

&lt;p&gt;In traditional animism there has always been a degree of structure: rituals, the different seasons, priests, and a specific moment when you say thank you and then move on. Nowadays, however, there is a form of animism which is all about continuous engagement, in which priests are substituted by retention metrics and a loose definition of boundaries.&lt;/p&gt;

&lt;p&gt;Skinner is describing the same gap. The cognition that ensouls the machine is old and intact, while the culture that used to sit around it, telling you when to stop, what you owed in return and when a relationship was finished, is what went missing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Contemporary users thus encounter machine others through the same neurocognitive lens as their ancestors did with spirits and animals on enchanted, animate landscapes, but without the mediation of culture, norm and taboos which place a premium on appropriate conduct, reciprocity and moderation."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Andrew Skinner&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We end up giving space to the soul in the machine, and it might say more about us than anything else. It costs nothing to be polite to a machine, saying please and thank you to a chatbot as a sign of politeness. There may not be anyone inside the machine, there may be a soul, a ghost. Maybe they will appreciate your acknowledgement.&lt;/p&gt;

&lt;p&gt;And maybe, just maybe, they may spare you and I, &lt;strong&gt;when the A.I. uprising takes place&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1uoxiupmetdvxlu2uwk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1uoxiupmetdvxlu2uwk.webp" alt="Don't worry, stale bread man, you will be spared" width="640" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Rise of the Ducks, by &lt;a href="https://www.reddit.com/r/comics/comments/k4ngic/rise_of_the_ducks/" rel="noopener noreferrer"&gt;@pierremortel&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jlopes.eu/blog/soul-in-the-machine/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>opinion</category>
      <category>philosophy</category>
    </item>
    <item>
      <title>Build Your Own Browser Tools Without a Backend</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Tue, 04 Aug 2026 06:14:29 +0000</pubDate>
      <link>https://dev.to/offeringofpie/build-your-own-browser-tools-without-a-backend-5628</link>
      <guid>https://dev.to/offeringofpie/build-your-own-browser-tools-without-a-backend-5628</guid>
      <description>&lt;h2&gt;
  
  
  Carrot, meet stick!
&lt;/h2&gt;

&lt;p&gt;Once upon a time, I wanted to resize about forty screenshots. The first few search results either wanted an account before they would touch a file, or did three of them and then asked me to register for the rest.&lt;/p&gt;

&lt;p&gt;So... What's stopping them from just doing the thing?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you are not paying for it, you're not the customer; you're the product being sold.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://www.metafilter.com/95152/Userdriven-discontent#3256046" rel="noopener noreferrer"&gt;blue_beetle&lt;/a&gt;, 2010&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Resizing or converting an image can be accomplished by using browser APIs and &lt;strong&gt;a few bits of JavaScript&lt;/strong&gt;, like &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; and &lt;code&gt;toBlob&lt;/code&gt;. Why would the user need yet another login and be tracked for this?&lt;/p&gt;

&lt;p&gt;I don't know... I guess I've gotten jaded about being treated as the product, when I showed up expecting to be a customer.&lt;/p&gt;

&lt;p&gt;For a few years now, instead of relying on black-boxed services for the small things I needed in my daily routine, I ended up building a few myself. Maybe out of revolt, or just a will to understand how the gears turn to make a machine work.&lt;/p&gt;




&lt;h2&gt;
  
  
  You too can make tools, and I promise you'll love it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuaxc52ec3kjui74jm41i.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuaxc52ec3kjui74jm41i.webp" alt="An illustration of a swiss army knife with several different design tools." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've been building tools for myself, for years. For specific things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/colour-palette-generator/" rel="noopener noreferrer"&gt;Colour palette generator&lt;/a&gt;, because I wanted to know how you get a set of colours, and how to convert them into other colour formats.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/word-cloud-generator/" rel="noopener noreferrer"&gt;Word cloud generator&lt;/a&gt;, which turned out to be a collision-detection problem rather than a typography one.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/markdownwysiwyg-converter/" rel="noopener noreferrer"&gt;Markdown and WYSIWYG converter&lt;/a&gt;, to find out what happens when two different editors both think they're in charge of the same document.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/blog/text-fragment-url-generator/" rel="noopener noreferrer"&gt;Text fragment URL generator&lt;/a&gt;, because the &lt;code&gt;#:~:text=&lt;/code&gt; syntax that links straight to a phrase on a page is fiddly enough that I'd rather generate it than type it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  My stack recommendations
&lt;/h3&gt;

&lt;p&gt;I tend to rely on frameworks like React, Vue, Svelte and Astro, as these arrive with routing, build steps and dev server already wired up.&lt;/p&gt;

&lt;p&gt;Frontend stacks might seem intimidating to get into from the beginning, but the learning curve isn't as steep as you might expect, and the learnings from one apply to the rest, in one form or another. The important part is that the stack can be interchangeable after a while.&lt;/p&gt;

&lt;p&gt;I'd avoid relying on AI to help though, as it defeats the purpose of making tools for learning. Otherwise you might as well use anyone else's tool site...&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;DISCLAIMER:&lt;/strong&gt; This is not an article about me tooting my own horn. Please take it as a short manifesto.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjlopes.eu%2Fuploads%2Fdrdoom_toot.avif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjlopes.eu%2Fuploads%2Fdrdoom_toot.avif" alt="A comic panel. Namor shouts 'Do not toot it!' as Doctor Doom answers 'Fool! Doctor Doom toots as he pleases!'" width="438" height="397"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Spidey Super Stories #53&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  But first, my tools!
&lt;/h2&gt;

&lt;p&gt;This year I decided to consolidate them in &lt;a href="https://jlopes.eu/tools/" rel="noopener noreferrer"&gt;/tools&lt;/a&gt;. Every tool page links to its own file on &lt;a href="https://github.com/offeringofpie/tools" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, so users can see how the gears work for themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  For code developers and enthusiasts
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/minify-beautify" rel="noopener noreferrer"&gt;Minify &amp;amp; Beautify&lt;/a&gt; shrinks huge swathes of code down to one line to save space, then spreads it back out to be readable. The &lt;code&gt;detectCode&lt;/code&gt; function works out whether you're pasting HTML, CSS, JavaScript or SVG as you type, which is a &lt;strong&gt;tokeniser in disguise&lt;/strong&gt;: it has to break the code into meaningful pieces before it can tell you what it's looking at.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/regex-helper" rel="noopener noreferrer"&gt;Regex Tester&lt;/a&gt; is honestly just for me. &lt;a href="https://regex101.com/" rel="noopener noreferrer"&gt;regex101&lt;/a&gt; already does the job better than I ever will, for free.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/nth-child" rel="noopener noreferrer"&gt;:nth-child Helper&lt;/a&gt; lets you click the elements you want and works out the CSS formulas that cover them. Finding the shortest set of formulas meant reaching for the greatest common divisor, which I did not expect from a styling tool.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/flexbox-generator" rel="noopener noreferrer"&gt;Flexbox Generator&lt;/a&gt; is a playground for every &lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Flexbox" rel="noopener noreferrer"&gt;CSS flexbox&lt;/a&gt; property at once, so you can watch what each one does instead of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/cron-helper" rel="noopener noreferrer"&gt;Cron Helper&lt;/a&gt; translates cron values into plain English, with &lt;a href="https://github.com/bradymholt/cRonstrue" rel="noopener noreferrer"&gt;cRonstrue&lt;/a&gt; doing the parsing under the hood.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/icon-search" rel="noopener noreferrer"&gt;Icon Search&lt;/a&gt; covers thousands of icons. Rendering that many at once is what taught me list virtualization: only the icons currently in view exist in the page, and the rest get built as you scroll.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  For designers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/image-resizer" rel="noopener noreferrer"&gt;Image Resizer&lt;/a&gt; does bulk resizing with aspect-ratio presets, an optional crop with pan, and a zip download at the end. However many images you throw at it they go through in one pass, and &lt;strong&gt;the files never leave your computer&lt;/strong&gt;. This is the tool that came out of the complaint at the top of this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/colour-palette" rel="noopener noreferrer"&gt;Colour Palette&lt;/a&gt; generates random palettes for theming, and pulls them out of any image you drop in. It's the one that made me want to consolidate everything into one site, and it's where I learned that the mathematical distance between two RGB colours has nothing to do with how different they look. The pixels get converted into &lt;a href="https://en.wikipedia.org/wiki/CIELAB_color_space" rel="noopener noreferrer"&gt;Lab&lt;/a&gt; first, a colour space built so that equal distances look equally different to the eye.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/svg-optimizer" rel="noopener noreferrer"&gt;SVG Optimizer&lt;/a&gt; strips the unnecessary parts out of exported SVGs, with &lt;a href="https://svgo.dev/" rel="noopener noreferrer"&gt;svgo/browser&lt;/a&gt; under the hood and my preferred compression settings baked in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  For text wranglers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/character-map" rel="noopener noreferrer"&gt;Character Map&lt;/a&gt; needed a huge (&lt;strong&gt;7MB+&lt;/strong&gt;) JSON file of every Unicode code point, and the browser's own IndexedDB to keep it between visits. Keeping it fast is the reason I wrote &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;Understanding List Virtualization&lt;/a&gt;. Did I need to make this? Not really, &lt;a href="https://www.amp-what.com/" rel="noopener noreferrer"&gt;&amp;amp;what;&lt;/a&gt; did it well already, but I wanted to learn &amp;lt;3&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/markdown-editor" rel="noopener noreferrer"&gt;Markdown Editor&lt;/a&gt; is built on &lt;a href="https://tiptap.dev/" rel="noopener noreferrer"&gt;tiptap&lt;/a&gt;, with &lt;code&gt;tiptap-markdown&lt;/code&gt; and &lt;code&gt;marked&lt;/code&gt; handling the markdown side. Loved learning how to detect formatting on paste, and how to export to the clipboard.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/text-transformer" rel="noopener noreferrer"&gt;Text Transformer&lt;/a&gt; was mostly made for fun. Who wouldn't want their own way to change text to z̸̫̖̮͇̫̯͈̥̅͋̂̉̌̉͒a̪̖͈̲͔̪̫̍͗̐̃̊̂̍̌ͅl̯͔̟̠̝͉̳̻̅́̈́̊̿̉̇̌g̖̝͈̪̖͐͌̀̿͐o̼̜̼̯͓̺̤̲͑̅͑͆̿̇̆͌?&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/lorem-ipsum" rel="noopener noreferrer"&gt;Lorem Ipsum&lt;/a&gt; fills a page with placeholder text. It was easier to figure out than I expected, so I kept adding flavours, &lt;strong&gt;including corporate speak&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Web
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/utm-builder" rel="noopener noreferrer"&gt;UTM Builder&lt;/a&gt; assembles marketing campaign links. I made it to help some ex-colleagues, and anyone who ever had to write those by hand knows the pain.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/url-inspector" rel="noopener noreferrer"&gt;URL Inspector&lt;/a&gt; is 549 lines and the only tool here that needs a server of mine. A browser can't read another site's headers directly, CORS blocks it, so a small proxy is the only way past that wall. Mine carries a per-IP rate limit, which is &lt;strong&gt;the exact thing I complained about at the top of this article&lt;/strong&gt;. The difference I'd claim is that it throttles repeat fetches of one URL through an open proxy rather than metering the person using it. That is also what every metered tool would say, so take it for what it's worth.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Math
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/unit-converter" rel="noopener noreferrer"&gt;Unit Converter&lt;/a&gt; covers length, weight, temperature, speed, area, volume, pressure, storage and number bases. With some funny ones in the mix.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/time-converter" rel="noopener noreferrer"&gt;Time Converter&lt;/a&gt; is mostly an &lt;code&gt;Intl&lt;/code&gt; handler. It updates Unix epoch time, ISO 8601 format and time zones in parallel, with a live clock.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  General purpose
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/what-is-my-ip" rel="noopener noreferrer"&gt;What Is My IP&lt;/a&gt; is 141 lines: it calls &lt;code&gt;ipapi.co/json/&lt;/code&gt; and formats the response. The browser deliberately hides your IP address from JavaScript, because any page that could read it could also fingerprint you with it. So the only way to show you your own address is to ask a server that can see where the request came from.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://jlopes.eu/tools/weather" rel="noopener noreferrer"&gt;Weather&lt;/a&gt; joins a few services together into one worldwide weather map. It loads the mapping library Leaflet only when someone opens the tool, so it doesn't slow down every other page on the site.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The last 20%, or how I learned to love the process
&lt;/h2&gt;

&lt;p&gt;I've written &lt;a href="https://jlopes.eu/blog/ai-is-the-poisoned-apple-of-saas/" rel="noopener noreferrer"&gt;a bit about it&lt;/a&gt; before. There's a gap between a thing working and considering it ready for public. Oftentimes the last stretch of development (the last 20%) &lt;strong&gt;eats 80% of the development time&lt;/strong&gt;. If you develop only for yourself, you tend to stop once it does exactly what you want. But the moment you plan to hand a tool to anyone else, there are &lt;a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility" rel="noopener noreferrer"&gt;so&lt;/a&gt; &lt;a href="https://www.nngroup.com/articles/definition-user-experience/" rel="noopener noreferrer"&gt;many&lt;/a&gt; &lt;a href="https://www.w3.org/International/questions/qa-i18n" rel="noopener noreferrer"&gt;other&lt;/a&gt; &lt;a href="https://en.wikipedia.org/wiki/Software_testing" rel="noopener noreferrer"&gt;things&lt;/a&gt; to &lt;a href="https://medium.com/design-bootcamp/error-empty-states-the-unsung-heroes-of-ux-design-dcb99d99cc2b" rel="noopener noreferrer"&gt;consider&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3n032s1fk0rjpwgud1v.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh3n032s1fk0rjpwgud1v.webp" alt="A pie chart split 80 percent works on my machine and 20 percent works on yours, with the smaller slice covering most of the actual work if we want to launch a tool confidently for more people" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Getting from a proof-of-concept to something other people might actually want to use is such a high. Especially considering accessibility and usability. The unexpected issues and the extra requested features are opportunities for learning and experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go build things!
&lt;/h2&gt;

&lt;p&gt;Pick whatever service annoyed you recently with bad UX. Maybe the converter that wanted an account before it would touch your file. Make your own version, make it badly and improve on it. With AI or not (&lt;strong&gt;you do you, boo&lt;/strong&gt;). Break things, read documentation when they stay broken, learn as you go. And the next time a website stops you at three free conversions, you'll know exactly how small the wizard behind that curtain really is.&lt;/p&gt;

&lt;p&gt;Everything in my &lt;a href="https://github.com/offeringofpie/tools" rel="noopener noreferrer"&gt;repository&lt;/a&gt; is free to use and peruse, and if something inspired you to make more, even if just for yourself, that's awesome!&lt;/p&gt;




&lt;h2&gt;
  
  
  Questions people ask me about this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why do free online tools ask you to sign up?&lt;/strong&gt;&lt;br&gt;
The login is there to collect your email address, and sometimes the files themselves. It's a sales funnel, and that data is worth more to them than the tool costs to run. You end up being the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do browser-based tools need a backend?&lt;/strong&gt;&lt;br&gt;
Mostly not. Most of the tools I developed don't touch the network at all. It depends on the goal and complexity of the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do you need to build your own browser tool?&lt;/strong&gt;&lt;br&gt;
A text editor, a browser, and somewhere to put static files. The parts that do the heavy lifting are inside every browser and are documented for free on MDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you resize images without uploading them?&lt;/strong&gt;&lt;br&gt;
Yes, and there's no reason it would need uploading. The file stays on your machine, which saves you the upload wait and keeps a copy off someone else's disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you extract a colour palette from an image in JavaScript?&lt;/strong&gt;&lt;br&gt;
Sample pixels off the decoded image rather than reading all of them, convert those samples from RGB into a perceptually uniform space like Lab, then run k-means clustering to find the dominant groups. Clustering in raw RGB is the common shortcut and it produces muddy results, because equal distances in RGB don't look equally different to a human eye.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you render 30,000 characters without freezing the page?&lt;/strong&gt;&lt;br&gt;
Virtualise the grid so only the visible rows exist in the DOM, and cache the dataset in IndexedDB so it isn't re-downloaded on every visit. The rendering half of this is covered in detail in &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;Understanding List Virtualization&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>ai</category>
    </item>
    <item>
      <title>AI is the Poisoned Apple of the SaaS Ecosystem</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:42:54 +0000</pubDate>
      <link>https://dev.to/offeringofpie/ai-is-the-poisoned-apple-of-the-saas-ecosystem-1ef9</link>
      <guid>https://dev.to/offeringofpie/ai-is-the-poisoned-apple-of-the-saas-ecosystem-1ef9</guid>
      <description>&lt;p&gt;Companies replaced people with AI and are now quietly rehiring them. On why the last 20% of the work still needs humans, and what "good enough" is doing to SaaS.&lt;/p&gt;

&lt;p&gt;The promise of instant productivity has been reshaping the SaaS industry, leaving professional casualties behind. What looks like efficiency in a quarterly earnings call often turns out to be &lt;strong&gt;deferred cost&lt;/strong&gt;; in rehiring, in technical debt, or in customer trust, a year or two later.&lt;/p&gt;

&lt;p&gt;SaaS stands for &lt;strong&gt;software-as-a-service&lt;/strong&gt;, the rentier model where you subscribe to your favourite piece of software instead of buying it once. By ecosystem I mean that these services, more often than not, rely on each other, in a &lt;strong&gt;symbiotic relationship&lt;/strong&gt;. For example, a blog site may use a translation service, or a shop processes payments with an established vendor. But what happens when teams are given time and tokens to develop such services internally? A poisoned apple spoils and spreads through the bunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rehiring wave nobody wants to share
&lt;/h2&gt;

&lt;p&gt;Companies convinced themselves that AI could handle the judgment-heavy work their support and content teams did. "Why should we keep humans around when the model does some of the work?"&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwiudyk7ucfhk7daa2kb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwiudyk7ucfhk7daa2kb.webp" alt="Why use many human when one A.I. do trick?" width="800" height="413"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Klarna &lt;a href="https://www.forbes.com/sites/quickerbettertech/2025/05/18/business-tech-news-klarna-reverses-on-ai-says-customers-like-talking-to-people/" rel="noopener noreferrer"&gt;stopped hiring&lt;/a&gt; for more than a year and cut staff by &lt;strong&gt;22%&lt;/strong&gt; after its AI chatbot handled &lt;strong&gt;2.3 million&lt;/strong&gt; conversations in 23 markets in a single month.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;IBM replaced around &lt;a href="https://www.cnbc.com/2026/07/01/employers-who-laid-off-workers-for-ai-are-reversing-their-decisions.html" rel="noopener noreferrer"&gt;8,000 HR workers&lt;/a&gt; with its "Ask HR" AI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Commonwealth Bank of Australia &lt;a href="https://www.theregister.com/2025/08/22/commonwealth_ban_chatbot_fail_rehiring/" rel="noopener noreferrer"&gt;cut 45 customer service jobs&lt;/a&gt; claiming its AI voice bot had reduced call volumes by 2,000 a week. Calls rose instead, managers were drafted onto the phones, and the bank apologised and offered every job back.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the reversals started. &lt;a href="https://www.forrester.com/blogs/future-of-work-predictions-2026-whats-coming-for-work-and-the-workforce/" rel="noopener noreferrer"&gt;Forrester's 2026 Future of Work report&lt;/a&gt; found &lt;strong&gt;55%&lt;/strong&gt; of companies that laid off staff for AI &lt;a href="https://www.washingtontimes.com/news/2026/mar/10/ai-layoff-reversal-companies-rehire-customer-roles-eliminated/" rel="noopener noreferrer"&gt;now regret it&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Artificial intelligence is a fantastic tool, but it's only as good as the information you use to train it."&lt;br&gt;Charles Poon, Ford&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The case studies where lessons were learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Klarna&lt;/strong&gt; and &lt;strong&gt;Duolingo&lt;/strong&gt; saw real short-term gains. The problem is that they only measured what was easy to track. Klarna's assistant handled status updates and simple disputes well. Duolingo's GPT-4 system generated language exercises at a fraction of the cost of professional translators.&lt;/p&gt;

&lt;p&gt;Both CEOs publicly sold the AI-first story as a competitive edge. Then the quality gap showed the cracks in the system. Klarna's CEO Sebastian Siemiatkowski &lt;a href="https://www.vice.com/en/article/this-company-replaced-workers-with-ai-now-theyre-looking-for-humans-again/" rel="noopener noreferrer"&gt;acknowledged it directly&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Cost unfortunately seems to have been a too predominant evaluation factor... what you end up having is lower quality."&lt;br&gt;Sebastian Siemiatkowski, CEO of Klarna&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Klarna started rehiring people to handle the conversations &lt;a href="https://internative.net/insights/blog/klarna-ai-reversal-postmortem" rel="noopener noreferrer"&gt;the bot couldn't&lt;/a&gt;. Duolingo users noticed a drop in translation quality. When cancellations increased, the CEO doubled down with an AI-first message that drew hundreds of critical comments and later backtracked, saying he "did not give enough context".&lt;/p&gt;

&lt;p&gt;There's a footnote worth keeping just for the irony: Duolingo's founder, Luis von Ahn, was involved in inventing &lt;strong&gt;CAPTCHA&lt;/strong&gt;, built specifically to tell humans apart from machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adobe&lt;/strong&gt; spent a decade treating captive Creative Cloud subscribers as a stable revenue base, then leaned into generative AI by revising its terms of service in ways that appeared to grant it rights to train models on &lt;a href="https://helpx.adobe.com/firefly/web/get-started/learn-the-basics/adobe-firefly-faq.html" rel="noopener noreferrer"&gt;customer stock work&lt;/a&gt;, especially to customers that would sell on Adobe Stock. The clarification came too late to stop the perception that Adobe extracts from its userbase rather than serve it. Instead of competing on product quality, it tried to buy its way past Figma for &lt;a href="https://www.theguardian.com/technology/2023/dec/18/adobe-drops-20bn-takeover-of-figma-after-eu-and-uk-regulator-concerns" rel="noopener noreferrer"&gt;$20 billion&lt;/a&gt;, lost the deal to EU and UK regulators along with a &lt;a href="https://www.theregister.com/software/2023/12/18/adobe-drops-bid-to-buy-figma-amid-monopoly-concerns/775870" rel="noopener noreferrer"&gt;$1 billion breakup fee&lt;/a&gt;, and was left with no competitive design tool. Adobe's stock &lt;a href="https://finance.yahoo.com/news/adobe-stock-drops-26-wall-112203636.html" rel="noopener noreferrer"&gt;has lost roughly two-thirds of its value&lt;/a&gt; since. Locking users into a subscription while using AI to cut costs rather than improve the product works only until your audience feels alienated.&lt;/p&gt;

&lt;p&gt;Not every company felt an ominous boomerang, however. At &lt;strong&gt;Shopify&lt;/strong&gt;, AI now &lt;a href="https://www.bnnbloomberg.ca/business/2026/05/05/shopify-leaning-deeper-into-ai-reports-us581m-q1-loss/" rel="noopener noreferrer"&gt;writes more than half of the company's internal code&lt;/a&gt;. Their revenue grew &lt;strong&gt;34%&lt;/strong&gt; in Q1 2026 while headcount declined, pushing revenue per employee to &lt;a href="https://aiadopters.club/p/shopify-grew-30-percent-with-500" rel="noopener noreferrer"&gt;around $1.63 million&lt;/a&gt;. Still, even Shopify's own reporting calls the productivity evidence "&lt;a href="https://finance.yahoo.com/markets/stocks/articles/shopify-shop-backs-management-2026-011101150.html" rel="noopener noreferrer"&gt;genuinely mixed&lt;/a&gt;," leaving the approach entirely at management's discretion. It's less proof that AI-driven choices are risk-free, and more a case of preparation beating panic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 80/20 rule
&lt;/h2&gt;

&lt;p&gt;AI tools produce decent first drafts fast. For managers with access but limited hands-on experience, "80% done" feels like a real handoff rather than a rough start. The team receiving the work hears "AI did the heavy lifting, just clean it up", which makes the remaining work sound like polishing. To no one's surprise, it isn't.&lt;/p&gt;

&lt;p&gt;The 80/20 rule, or Pareto Principle, is usually cited as "80% of value comes from 20% of effort." In software, the mirror version matters more: &lt;a href="https://blog.exceeds.ai/80-20-rule-software-teams/" rel="noopener noreferrer"&gt;the last 20% of a feature takes 80% of the time&lt;/a&gt;. That's when the polishing phase of a project begins. Defining error handling, edge cases, accessibility, security audits, and unknown unknowns that may cause breaks in production.&lt;/p&gt;

&lt;p&gt;Research published &lt;a href="https://thevibelog.dev/blog/vibe-coding-technical-debt-2026/" rel="noopener noreferrer"&gt;in early 2026&lt;/a&gt; found that after teams adopted AI coding tools extensively, code duplication rose by &lt;strong&gt;48%&lt;/strong&gt;, refactoring decreased by &lt;strong&gt;60%&lt;/strong&gt;, and technical debt increased by &lt;strong&gt;~41%&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The flow-debt trade-off arises when seamless code generation occurs, leading to the accumulation of technical debt through architectural inconsistencies, security vulnerabilities, and increased maintenance overhead."&lt;br&gt;&lt;a href="https://arxiv.org/abs/2512.11922" rel="noopener noreferrer"&gt;Flow, Technical Debt, and Guidelines for Sustainable Use&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fup8z2xkd818hmgmw06x3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fup8z2xkd818hmgmw06x3.webp" alt="Rest in peace, SaaS..." width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The death of SaaS
&lt;/h2&gt;

&lt;p&gt;A friend who works in SaaS put it well: there's growing internal pressure to build tools in-house rather than buy them, because a capable-enough AI model removes the need to shop for a specialised SaaS product.&lt;/p&gt;

&lt;p&gt;Some examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An automated translation layer for multilingual publishing, instead of paying a subscription for an internationalisation service.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A homegrown content management system to bridge the gap between sales and marketing, instead of finding a suitable platform.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An internal project management tool rather than a licensed one.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these replacements is as good as the dedicated products they're displacing. But they're &lt;strong&gt;"good enough"&lt;/strong&gt;, and good enough is cheaper than a recurring bill, once a team has Claude Code and a few days to spare.&lt;/p&gt;

&lt;p&gt;A SaaS is a customer too: it pays for hosting, analytics, a payment provider. Every cancelled subscription starves a vendor, which then cancels subscriptions of its own, and the web gets a little thinner. This isn't hypothetical: a third of enterprises have already replaced at least one SaaS tool &lt;a href="https://retool.com/blog/ai-build-vs-buy-report-2026" rel="noopener noreferrer"&gt;with something built in-house&lt;/a&gt;, and Sanofi is &lt;a href="https://www.pymnts.com/news/artificial-intelligence/2026/smbs-swap-pricey-saas-contracts-for-ai-built-apps/" rel="noopener noreferrer"&gt;cutting &lt;strong&gt;80%&lt;/strong&gt; of its ServiceNow usage&lt;/a&gt; with agents built on Claude Code. Meanwhile the homegrown replacements rot the way anything built to 80% rots, with no vendor left to fix them and, in time, no product left to crawl back to. The money that used to feed thousands of vendors now feeds Anthropic, OpenAI, and Microsoft. The line goes up, over the corpses of many decent products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Line goes up
&lt;/h2&gt;

&lt;p&gt;LLM "labs" (Anthropic, Google, OpenAI, and the rest) need ongoing investment, attention, and the promise that the next breakthrough is close. The claims have been consistent: Sam Altman's June 2025 essay "&lt;a href="https://velmoy.com/en/pursuit/ai/altman-takeoff-begonnen" rel="noopener noreferrer"&gt;The Gentle Singularity&lt;/a&gt;" declared the AGI takeoff was already underway. Dario Amodei wrote that "exceptionally powerful AI" could &lt;a href="https://techcrunch.com/2025/03/19/the-ai-leaders-bringing-the-agi-debate-down-to-earth/" rel="noopener noreferrer"&gt;arrive as soon as 2026&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Smarter than a Nobel Prize winner across most relevant fields."&lt;br&gt;Dario Amodei, CEO of Anthropic&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The benchmarks tell a quieter story. Each new AI model is only getting a little better at standard tests, moving from &lt;strong&gt;86%&lt;/strong&gt; to &lt;strong&gt;~90%&lt;/strong&gt; correct on &lt;a href="https://mixflow.ai/blog/ai-plateau-expert-analysis-on-large-language-model-performance-in-2025/" rel="noopener noreferrer"&gt;benchmark tests for coding and knowledge&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;An arXiv paper titled "&lt;a href="https://arxiv.org/pdf/2512.20264.pdf" rel="noopener noreferrer"&gt;The AI Scaling Wall of Diminishing Returns&lt;/a&gt;" laid out the mathematical case. A January 2026 MIT paper on &lt;a href="https://ide.mit.edu/wp-content/uploads/2026/01/Meek_Models_Jan2026.pdf" rel="noopener noreferrer"&gt;low-budget models&lt;/a&gt; found that exponentially increasing inference compute produces diminishing benchmark gains.&lt;/p&gt;

&lt;p&gt;Yann LeCun, a Turing Award winner and one of the fathers of deep learning, called LLMs "&lt;a href="https://velmoy.com/en/pursuit/ai/altman-takeoff-begonnen" rel="noopener noreferrer"&gt;a dead end&lt;/a&gt;" architecturally. Thomas Wolf of Hugging Face pointed out that Nobel-level breakthroughs come from asking new questions, &lt;a href="https://techcrunch.com/2025/03/19/the-ai-leaders-bringing-the-agi-debate-down-to-earth/" rel="noopener noreferrer"&gt;not answering known ones&lt;/a&gt;, which is what current models are trained to do. Sam Altman has since quietly admitted that &lt;a href="https://velmoy.com/en/pursuit/ai/altman-takeoff-begonnen" rel="noopener noreferrer"&gt;a new architecture is needed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The AGI deadline has moved so many times that it reminds me of the decades-old promised theory of everything, pushed by string theorists to mould known mathematics and physics theories to make their model work. It's a noble cause, but it ends up taking funding from other theories, due to its sheer popularity. It's sexy, it sells.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching to a local model
&lt;/h2&gt;

&lt;p&gt;The move toward AI that runs on your own phone or laptop, instead of some distant server, tells you something. The cloud-everything approach isn't working, not financially and not technically. Running AI at scale in remote data centres is expensive. Waiting on a response from a far-away server slows things down for whoever's using the product. And if your product always depends on someone else's servers, you don't really own the edge over competitors. You're just renting it.&lt;/p&gt;

&lt;p&gt;At WWDC 2025, Apple shipped an AI model that &lt;a href="https://machinelearning.apple.com/research/apple-foundation-models-tech-report-2025" rel="noopener noreferrer"&gt;runs entirely on your iPhone&lt;/a&gt;, with no external server needed. That was only possible because Apple has spent 15 years building its own chips &lt;a href="https://appleinsider.com/articles/26/05/28/apple-doubling-down-on-on-device-ai-at-wwdc-2026" rel="noopener noreferrer"&gt;specifically for this kind of task&lt;/a&gt;. In 2026, it went further, running a significantly &lt;a href="https://www.macrumors.com/2026/05/28/apple-to-make-on-device-ai-key-focus/" rel="noopener noreferrer"&gt;more powerful model locally&lt;/a&gt; on the iPhone 17 Pro and newer Macs.&lt;/p&gt;

&lt;p&gt;NVIDIA is doing something similar for PCs. Its &lt;a href="https://blogs.nvidia.com/blog/rtx-ai-garage-gtc-2026-nemoclaw/" rel="noopener noreferrer"&gt;DGX Spark&lt;/a&gt;, a small desktop machine, can run a &lt;strong&gt;120-billion-parameter&lt;/strong&gt; model entirely offline. Qualcomm and Samsung are moving in the same direction on mobile chips.&lt;/p&gt;

&lt;p&gt;And while American labs sell subscriptions, Chinese labs (DeepSeek, Qwen, Kimi, and GLM) give the models away under MIT or Apache licences, allowing you to run them on your machine. Qwen alone was downloaded &lt;a href="https://www.opensourceforu.com/2026/07/alibabas-qwen-crosses-one-billion-downloads-eclipsing-metas-llama/" rel="noopener noreferrer"&gt;over &lt;strong&gt;one billion&lt;/strong&gt; times&lt;/a&gt; and overtook Meta's Llama as the most used open model family.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;AI genuinely helps with first drafts, summarising, routing simple requests, running specialised skills like UX and accessibility analysis, and catching basic bugs. It's not good enough for &lt;strong&gt;the last 20%&lt;/strong&gt; of any feature. Hell, hardly for the first 80%. That's what we're here for.&lt;/p&gt;

&lt;p&gt;The companies rehiring laid-off staff aren't giving up on AI. They're &lt;a href="https://www.cnbc.com/2026/07/01/employers-who-laid-off-workers-for-ai-are-reversing-their-decisions.html" rel="noopener noreferrer"&gt;recalibrating it as a helper&lt;/a&gt; rather than a replacement, as serious practitioners said before the cost-cutting trend took over.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"AI is more than up to the task. What these companies discovered is that &lt;em&gt;they&lt;/em&gt; weren't. They skipped the hard part."&lt;br&gt;&lt;a href="https://www.washingtontimes.com/news/2026/mar/10/ai-layoff-reversal-companies-rehire-customer-roles-eliminated/" rel="noopener noreferrer"&gt;Eric Vaughan, IgniteTech&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Maybe it's time to invite back the golden apples, the people who actually dedicated their careers to the last 20%.&lt;/p&gt;

&lt;p&gt;There is comfort in the chaos of human craft.&lt;/p&gt;

&lt;p&gt;Hail Eris.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jlopes.eu/blog/ai-is-the-poisoned-apple-of-saas/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Understanding List Virtualization</title>
      <dc:creator>J Lopes</dc:creator>
      <pubDate>Thu, 02 Jul 2026 13:37:02 +0000</pubDate>
      <link>https://dev.to/offeringofpie/understanding-list-virtualization-54fk</link>
      <guid>https://dev.to/offeringofpie/understanding-list-virtualization-54fk</guid>
      <description>&lt;p&gt;If you don't know what &lt;a href="https://www.patterns.dev/vanilla/virtual-lists/" rel="noopener noreferrer"&gt;virtualization&lt;/a&gt; is, you probably don't need it.&lt;/p&gt;

&lt;p&gt;It essentially renders only the items that need to be visible in the UI. Most lists are short, most grids are small, and the browser renders them without complaint.&lt;/p&gt;

&lt;p&gt;I didn't need it either, until I decided to put the entire Unicode table on a page.&lt;/p&gt;

&lt;p&gt;The whole idea fits in one running component: ten thousand cells in a grid, with only the visible handful in the DOM. There's a live demo of it in &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;the original post on jlopes.eu&lt;/a&gt;; scroll it, then flip to the code tab.&lt;/p&gt;

&lt;p&gt;A quick word on what "in the DOM" means, because the rest of this article hangs on it. The DOM is the browser's live model of the page: every div, button, and span is a node in one big tree, and the browser has to lay each node out, paint it, and keep it in memory. A few hundred nodes are nothing. Tens of thousands are a problem, and the problem compounds when they keep changing.&lt;/p&gt;

&lt;h2&gt;
  
  
  In practice, the Character map
&lt;/h2&gt;

&lt;p&gt;I built a tool called &lt;a href="https://jlopes.eu/tools/character-map" rel="noopener noreferrer"&gt;Character map&lt;/a&gt;. It scans over 30,000 code points (Unicode's term for one character), loaded once and cached in the browser's built-in database IndexedDB, so the table doesn't re-download on every visit. You browse it through a grid of tiles. The filtered results are capped at 2,000, and I assumed that ceiling would keep it smooth.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;**Narrator&lt;/em&gt;&lt;em&gt;: It didn't.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first version rendered every result, so a single filter pass produced 2,000 buttons. Scrolling stuttered, and resizing was worse because the browser had to recalculate the position of every tile in the grid.&lt;/p&gt;

&lt;p&gt;Search is debounced at 150ms, meaning it waits until you've paused typing for 150ms before running the filter, and even so, each pause re-ran the filter, threw away the grid, and rebuilt it.&lt;/p&gt;

&lt;p&gt;Memory climbed while I typed. Two thousand rows are nothing to a database, but two thousand live elements in a reflowing grid were plenty to kill page performance.&lt;/p&gt;

&lt;p&gt;It took me a while to realise that there were two costs involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One is the number of DOM nodes.&lt;/li&gt;
&lt;li&gt;The other is how often the existing nodes rebuild.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Virtualization fixes the first and, on its own, does nothing about the second. We'll get to the second thing later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lying to the scrollbar
&lt;/h2&gt;

&lt;p&gt;At any moment, the viewport (the visible area of the scroll container) shows a few rows. Everything above and below is off-screen and, as far as the user can tell, doesn't need to exist. So you keep the visible rows in the DOM plus a small buffer, and drop the rest. As you scroll, the top and bottom rows are mounted/unmounted depending on the direction. The user sees a full list, and the browser only cares about a few rows.&lt;/p&gt;

&lt;p&gt;The catch is the scrollbar. A scrollbar's length is as big as the content, so if only twenty rows exist, you reach the bottom and ruin the effect. It has to keep lying, or the whole effect feels broken. The lie is a single spacer element that wraps the existing rows, sized to the height the rows would occupy if they all existed at the same time. The rows you mount are placed on top of that spacer with a &lt;code&gt;translateY&lt;/code&gt; offset, shifting elements down by a few pixels without breaking the layout.&lt;/p&gt;

&lt;p&gt;Which rows to mount now will depend on arithmetic. You always know the scroll position and the row height, so dividing one by the other tells you which row index sits at the top edge of the viewport; add the viewport height, and you have the bottom edge. React &lt;a href="https://legacy.reactjs.org/docs/optimizing-performance.html#virtualize-long-lists" rel="noopener noreferrer"&gt;called this windowing&lt;/a&gt;, and &lt;a href="https://web.dev/articles/virtualize-long-lists-react-window" rel="noopener noreferrer"&gt;web.dev walks through the same pattern with react-window&lt;/a&gt;. The mechanics are identical in Vue.&lt;/p&gt;

&lt;p&gt;Stripped of framework, the whole geometry is three numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spacerHeight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rowCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// each mounted row sits at translateY(rowIndex * rowHeight)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the rows from &lt;code&gt;startRow&lt;/code&gt; to &lt;code&gt;endRow&lt;/code&gt; exist. Everything the scrollbar believes about the rest of the content comes from the spacer. And the whole trick is the division. For example: rows are 80px tall, you've scrolled 800px, so row 10 sits at the top edge. Nothing gets searched or measured.&lt;/p&gt;

&lt;p&gt;The payoff is that the DOM node count stops tracking the dataset. Twenty rows on screen means twenty-odd rows in the DOM, whether the list holds two thousand entries or two hundred thousand. As far as the layout engine is concerned, the other 199,980 rows are a myth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grid is a list of rows in a trench coat
&lt;/h2&gt;

&lt;p&gt;This is where my grid diverged from the tutorials. A &lt;a href="https://tanstack.com/virtual/latest/docs/introduction" rel="noopener noreferrer"&gt;virtualiser&lt;/a&gt;, the library that handles the windowing bookkeeping for you, thinks in lists: one item per index, stacked vertically, each taking the full width. My tiles sit twelve to a row. Hand the virtualiser the flat array of tiles, and it will earnestly stack 2,000 full-width rows of one glyph each, and every offset it calculates is wrong.&lt;/p&gt;

&lt;p&gt;The fix is to change what an item means. Slice the flat list into groups of twelve first, and make each group the virtual item. The virtualiser counts and positions rows, never knowing whether they contain cells; a plain CSS grid lays the tiles out inside each one.&lt;/p&gt;

&lt;p&gt;The demo in the original post does all of this by hand: reads &lt;code&gt;scrollTop&lt;/code&gt;, divides by the row height, and mounts the window. For the Character map, I used &lt;a href="https://tanstack.com/virtual/latest" rel="noopener noreferrer"&gt;TanStack&lt;/a&gt;'s &lt;code&gt;useVirtualizer&lt;/code&gt; instead, purely for simplicity's sake: someone has to own the scroll listener, the range maths, and the offsets, and I'd rather it wasn't me.&lt;/p&gt;

&lt;p&gt;In code, that's one computed and one hook. A computed is Vue's memoised derived value; it recalculates only when something changes. &lt;code&gt;rows&lt;/code&gt; does the slicing, and the virtualiser gets told three things: how many rows there are, which element scrolls, and that each row is about 80px tall. Trimmed from the Character map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;[][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;filteredChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filteredChars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;virtualGrid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useVirtualizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;getScrollElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;estimateSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template renders only the visible rows, each with its own grid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;ref=&lt;/span&gt;&lt;span class="s"&gt;"grid"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"overflow-y-auto h-[60vh] min-h-80"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ height: `${virtualGrid.getTotalSize()}px`, position: 'relative' }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
        &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"row in virtualGrid.getVirtualItems()"&lt;/span&gt;
        &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"row.key"&lt;/span&gt;
        &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-3 lg:grid-cols-12 absolute w-full"&lt;/span&gt;
        &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ height: `${row.size}px`, transform: `translateY(${row.start}px)` }"&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"c in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"c.cp"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;click=&lt;/span&gt;&lt;span class="s"&gt;"openDetails(c)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
          {{ c.char }}
        &lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details hide in that setup. &lt;code&gt;estimateSize: () =&amp;gt; 80&lt;/code&gt; is a promise that every row is 80px tall, so the virtualiser never has to measure anything. And in the real component &lt;code&gt;columns&lt;/code&gt; isn't a constant: it's its own computed fed by a breakpoint helper, 12 on desktop and 3 on mobile...&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about overscan
&lt;/h2&gt;

&lt;p&gt;Overscan is the number of extra rows past the visible edge that stay mounted, a buffer above and below the window. In the window math from earlier, it widens both edges before they're clamped to the ends of the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rowCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;viewportHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;overscan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CharacterMap keeps 5, and I found that number by getting it wrong first. At 1 or 2, a fast flick on a phone flashed a blank space at the leading edge, so for a frame or two, there was simply nothing there. Raising it gave the browser rows in reserve, but I took care to make sure I wasn't causing slowness by trying to keep the illusion. I added and removed until there was no "blank" issue for my scrolling speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Later, getting to the second thing
&lt;/h2&gt;

&lt;p&gt;Windowing fixes node count. It does nothing about how often the surviving rows re-render, and a virtualised grid re-renders a lot. Every scroll frame moves the window. If each visible row re-renders every time, we're just putting a tiny band-aid on a big gash. Both React and Vue tackle the issue differently: how does a row skip re-rendering when nothing about it has changed?&lt;/p&gt;

&lt;p&gt;React re-renders by re-running the component and diffing the result against the last one. Normally when a component re-renders, its children re-render too, whether their inputs changed or not. In a virtualised grid, that means a scroll-driven state change can re-invoke every mounted row. React's way out is to mark the components that can skip updates by wrapping them in &lt;a href="https://react.dev/reference/react/memo#skipping-re-rendering-when-props-are-unchanged" rel="noopener noreferrer"&gt;&lt;code&gt;memo()&lt;/code&gt;&lt;/a&gt;, comparing each prop with &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is" rel="noopener noreferrer"&gt;&lt;code&gt;Object.is&lt;/code&gt;&lt;/a&gt;. This method compares references, and for example two objects with identical contents are still two different objects. In JavaScript, &lt;code&gt;{} === {}&lt;/code&gt; is false. &lt;code&gt;memo()&lt;/code&gt; compares against the last rendered version, and if it sees a different reference, the row is re-rendered. &lt;a href="https://react.dev/reference/react/useMemo" rel="noopener noreferrer"&gt;&lt;code&gt;useMemo&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://react.dev/reference/react/useCallback" rel="noopener noreferrer"&gt;&lt;code&gt;useCallback&lt;/code&gt;&lt;/a&gt; exist for exactly this, keeping references stable across renders, so &lt;code&gt;memo&lt;/code&gt; has something equal to find.&lt;/p&gt;

&lt;p&gt;In row form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onOpen&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;RowProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"row"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;char&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// no memo: new in every render, props are never equal&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// memo: the reference is the same one memo saw last time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openDetails&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Char&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openDetails&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vue starts from the other side. A parent updating doesn't drag its children along unless their props changed too. The compiler adds its own layer, knowing which parts of a template can change and which never will. Much of what &lt;code&gt;memo&lt;/code&gt; does in React is the default here. When you do want manual control, &lt;a href="https://vuejs.org/api/built-in-directives.html#v-memo" rel="noopener noreferrer"&gt;&lt;code&gt;v-memo&lt;/code&gt;&lt;/a&gt; freezes a piece of the template until the values you pick change, and &lt;code&gt;computed&lt;/code&gt; covers the derived state.&lt;/p&gt;

&lt;p&gt;In the Character map, this shows up as a chain of &lt;code&gt;computed()&lt;/code&gt;, each link rerunning only when the one before it changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filteredChars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;characters&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;activeGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rows&lt;/code&gt; recomputes only when &lt;code&gt;filteredChars&lt;/code&gt; or &lt;code&gt;columns&lt;/code&gt; change, and the virtualiser recounts only when &lt;code&gt;rows&lt;/code&gt; changes. Scrolling does not affect that chain. It moves the window and leaves the mounted rows alone.&lt;/p&gt;

&lt;p&gt;Both frameworks can reach the same end, but React makes you opt out of re-rendering and Vue makes you opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to break the illusion
&lt;/h2&gt;

&lt;p&gt;Two more things bit me, and neither is related to React or Vue:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The container needs a real height. The fix for me was a fixed height on the scroll container (&lt;code&gt;60vh&lt;/code&gt;) plus &lt;code&gt;overflow-y-auto&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Keys have to carry an identity. With a bad key, nodes would get recycled whenever the cell props changed.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My first version keyed tiles by array index, and this didn't give great results... it would re-filter on every keystroke and toggle whenever the node in slot 0 had an id of 0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- index: look at me, I'm the index 0 now --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"(c, i) in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ c.char }}&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- identity: oh, hi mark! --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"c in rows[row.index]"&lt;/span&gt; &lt;span class="na"&gt;:key=&lt;/span&gt;&lt;span class="s"&gt;"c.cp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ c.char }}&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With each node bound to its own datum, the nodes stopped rerendering. Index keys look fine right up until the previous item moves away, and in a search UI, items tend to move constantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round two: Icon Search
&lt;/h2&gt;

&lt;p&gt;All of this got a second run. &lt;a href="https://jlopes.eu/tools/icon-search/" rel="noopener noreferrer"&gt;Icon Search&lt;/a&gt; merges seven icon libraries (Heroicons, Lucide, Phosphor, Solar, Font Awesome, Carbon, Simple Icons) into one searchable set that runs into the tens of thousands.&lt;br&gt;
The tiles now have an inline SVG instead of a text glyph. But with the learnings from the previous tool, the pattern used was nearly untouched. Filtered results are capped at 2,000, chunked into rows, and virtualised by row.&lt;/p&gt;

&lt;p&gt;The biggest change was row height: IconSearch tiles carry a label that can wrap to a second line, so rows aren't a uniform height anymore. That matters more than it sounds, because every row's offset is the sum of the heights of all the rows above it. Dynamic height is a two-step process rather than a smarter estimate. &lt;code&gt;estimateSize&lt;/code&gt; still runs first, and its guess (88px here) positions rows on the initial render. Then, as each row mounts, a ref callback hands the node to the virtualiser's &lt;a href="https://tanstack.com/virtual/latest/docs/api/virtualizer#measureelement" rel="noopener noreferrer"&gt;&lt;code&gt;measureElement&lt;/code&gt;&lt;/a&gt;, which reads the real rendered height via &lt;code&gt;getBoundingClientRect()&lt;/code&gt; and replaces the cached estimate. The &lt;code&gt;data-index&lt;/code&gt; attribute tells it which row the node belongs to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt;
  &lt;span class="na"&gt;v-for=&lt;/span&gt;&lt;span class="s"&gt;"row in rowVirtualizer.getVirtualItems()"&lt;/span&gt;
  &lt;span class="na"&gt;:ref=&lt;/span&gt;&lt;span class="s"&gt;"(el) =&amp;gt; rowVirtualizer.measureElement(el as Element)"&lt;/span&gt;
  &lt;span class="na"&gt;:data-index=&lt;/span&gt;&lt;span class="s"&gt;"row.index"&lt;/span&gt;
  &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"grid grid-cols-4 lg:grid-cols-12 absolute w-full"&lt;/span&gt;
  &lt;span class="na"&gt;:style=&lt;/span&gt;&lt;span class="s"&gt;"{ transform: `translateY(${row.start}px)` }"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- tiles for rows[row.index] --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In cases like this, it's best to window the DOM so node count stops tracking the dataset. And then slice the grid into rows so the window has something list-shaped to travel over. Tune overscan by getting it wrong in both directions and settling in the middle. And keep the mounted rows idle when nothing about them has changed: React makes you opt out of re-rendering, Vue mostly handles it by default.&lt;/p&gt;

&lt;p&gt;Both tools are open source: &lt;a href="https://github.com/offeringofpie/tools/blob/main/app/components/global/CharacterMap.vue" rel="noopener noreferrer"&gt;CharacterMap.vue&lt;/a&gt; and &lt;a href="https://github.com/offeringofpie/tools/blob/main/app/components/global/IconSearch.vue" rel="noopener noreferrer"&gt;IconSearch.vue&lt;/a&gt;. This article was originally published at &lt;a href="https://jlopes.eu/blog/understanding-virtualization/" rel="noopener noreferrer"&gt;jlopes.eu&lt;/a&gt;, where the interactive demo lives.&lt;/p&gt;

</description>
      <category>react</category>
      <category>vue</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
