<?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: pop3zxcv</title>
    <description>The latest articles on DEV Community by pop3zxcv (@pop3_zxcv).</description>
    <link>https://dev.to/pop3_zxcv</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%2F4061144%2Fb521fe90-d169-4c2a-9ae2-18f7de95c1f9.jpg</url>
      <title>DEV Community: pop3zxcv</title>
      <link>https://dev.to/pop3_zxcv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pop3_zxcv"/>
    <language>en</language>
    <item>
      <title>Four things SVG and CSS did that I did not expect</title>
      <dc:creator>pop3zxcv</dc:creator>
      <pubDate>Sat, 22 Aug 2026 12:46:28 +0000</pubDate>
      <link>https://dev.to/pop3_zxcv/four-things-svg-and-css-did-that-i-did-not-expect-4f4d</link>
      <guid>https://dev.to/pop3_zxcv/four-things-svg-and-css-did-that-i-did-not-expect-4f4d</guid>
      <description>&lt;p&gt;I spent a while building an icon editor that runs entirely in the browser&lt;br&gt;
(icons.jamuny.com, free, no account). Here is what cost me the most time.&lt;/p&gt;
&lt;h3&gt;
  
  
  A presentation attribute loses to any author CSS rule
&lt;/h3&gt;

&lt;p&gt;I was scaling handle stroke widths by &lt;code&gt;1 / zoom&lt;/code&gt; and writing the result as an&lt;br&gt;
attribute. The value was never used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stroke-width&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.35&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;zoom&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;.handle { stroke-width: 0.35 }&lt;/code&gt; in the stylesheet outranks it, because a&lt;br&gt;
presentation attribute sits at the very bottom of the cascade.&lt;/p&gt;

&lt;p&gt;Measured in Chromium: an attribute of &lt;code&gt;0.05&lt;/code&gt; computed as &lt;code&gt;0.35px&lt;/code&gt;. Every handle&lt;br&gt;
thickened on screen as you zoomed in, for months, with no error anywhere.&lt;/p&gt;

&lt;p&gt;The fix is a custom property, which is an ordinary declaration and wins where an&lt;br&gt;
attribute cannot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="cm"&gt;/* .handle { stroke-width: calc(0.35 * var(--px)) } */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Geometry attributes like &lt;code&gt;r&lt;/code&gt; and &lt;code&gt;width&lt;/code&gt; are unaffected. They have no CSS&lt;br&gt;
counterpart here, so nothing was ever overriding them.&lt;/p&gt;
&lt;h3&gt;
  
  
  A focused SVG element gets a focus ring measured in user units
&lt;/h3&gt;

&lt;p&gt;My canvas is 24 units wide and about 620 pixels. Chrome drew its default focus&lt;br&gt;
ring at &lt;code&gt;outline-width: 2.72727px&lt;/code&gt; in &lt;em&gt;user&lt;/em&gt; units, which is about 24 screen pixels.&lt;/p&gt;

&lt;p&gt;A fat blue disc appeared around every point you clicked. It was reported to me&lt;br&gt;
four times, and four times I thinned something of my own that was not the cause.&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="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;outline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line found it. My rule only covered &lt;code&gt;:focus-visible&lt;/code&gt;, which is the&lt;br&gt;
keyboard case, and the keyboard case is the one where I draw a ring of my own.&lt;/p&gt;
&lt;h3&gt;
  
  
  var() does work in a presentation attribute, and I wrote down that it doesn't
&lt;/h3&gt;

&lt;p&gt;I needed a segment colour that changes with the theme, so the value is&lt;br&gt;
&lt;code&gt;oklch(var(--band-l) var(--band-c) 47)&lt;/code&gt;. I applied it through a style and put a&lt;br&gt;
comment beside it saying &lt;code&gt;var()&lt;/code&gt; is not substituted in presentation attributes.&lt;/p&gt;

&lt;p&gt;It is. Both forms compute to the same colour, including on an element built&lt;br&gt;
detached and appended afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stroke&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oklch(var(--band-l) var(--band-c) 47)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stroke&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oklch(var(--band-l) var(--band-c) 47)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// both: "oklch(0.52 0.09 47)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing broke, because both work. But the comment passed a full test suite and a&lt;br&gt;
deploy, which is the point: a claim about the browser that nobody pointed a&lt;br&gt;
browser at is worth exactly nothing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Making an overlay translucent breaks contrast in a way tests cannot see
&lt;/h3&gt;

&lt;p&gt;Each drawing command wears its own colour band over the icon. A request came in&lt;br&gt;
to make those bands 74% opaque instead of solid.&lt;/p&gt;

&lt;p&gt;A translucent colour takes on whatever is behind it, so one palette stopped&lt;br&gt;
serving both themes. Over white it landed at 2.29:1 against that white, under&lt;br&gt;
the 3:1 that WCAG 1.4.11 asks of a graphic.&lt;/p&gt;

&lt;p&gt;I swept the lightness range and no single value clears the bar in both themes at&lt;br&gt;
that opacity. So the lightness became a theme token: 0.52 on light, 0.74 on dark.&lt;/p&gt;

&lt;p&gt;My contrast test stayed green through all of it, because it read the band's raw&lt;br&gt;
&lt;code&gt;stroke&lt;/code&gt; and a raw colour says nothing about opacity. It composites now:&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;over&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;colour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;backdrop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alpha&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;colour&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;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&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;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;backdrop&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked the fix by forcing each theme's lightness onto both and watching the&lt;br&gt;
test go red. A test you have not seen fail is not known to protect anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  One more thing about palettes
&lt;/h3&gt;

&lt;p&gt;I had a tab of coolors.co open on "soft" palettes while doing this. Pulled the&lt;br&gt;
hex values out of the top twelve and measured them.&lt;/p&gt;

&lt;p&gt;Every one lands between 5.6:1 and 6.1:1 on a dark ground and between 1.03:1 and&lt;br&gt;
1.08:1 on a white one. They are dark-ground palettes, not soft ones.&lt;/p&gt;




&lt;p&gt;The editor is at icons.jamuny.com. No account, no upload, and the CSP says&lt;br&gt;
&lt;code&gt;connect-src 'none'&lt;/code&gt; so it could not upload anything if I wanted it to.&lt;/p&gt;

</description>
      <category>svg</category>
      <category>webdev</category>
      <category>css</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I built a Chrome extension that makes webpages easier to read Universal Web Accessibility Companion is now available on the Chrome Web Store.</title>
      <dc:creator>pop3zxcv</dc:creator>
      <pubDate>Thu, 13 Aug 2026 13:13:41 +0000</pubDate>
      <link>https://dev.to/pop3_zxcv/i-built-a-chrome-extension-that-makes-webpages-easier-to-read-universal-web-accessibility-companion-3i6g</link>
      <guid>https://dev.to/pop3_zxcv/i-built-a-chrome-extension-that-makes-webpages-easier-to-read-universal-web-accessibility-companion-3i6g</guid>
      <description>&lt;p&gt;Small text, tight spacing, distracting layouts and unfamiliar letter shapes can make ordinary webpages tiring to read.&lt;/p&gt;

&lt;p&gt;Universal Web Accessibility Companion is made for people who have trouble reading standard webpage layouts. It is now available on the Chrome Web Store.&lt;/p&gt;

&lt;p&gt;The extension can enlarge text, change letter shapes and spacing, focus attention on one line at a time, add a reading ruler or read the page aloud. Settings can be saved for individual websites, and the original page can be restored with one click.&lt;/p&gt;

&lt;p&gt;The CSS controls were fairly straightforward. Compatibility took most of the time.&lt;/p&gt;

&lt;p&gt;A blanket font or spacing rule can easily damage forms, code editors, menus, and web applications. Much of the development work went into identifying those areas, leaving them alone, and restoring the original page cleanly when the extension is turned off.&lt;/p&gt;

&lt;p&gt;Privacy was another firm requirement. Page content is handled inside the browser. There is no UWAC server, tracking system, advertising network, or account.&lt;/p&gt;

&lt;p&gt;There are honest limits. It is not a screen reader, Chrome prevents extensions from changing some protected pages, and no extension can repair every accessibility problem on every website.&lt;/p&gt;

&lt;p&gt;If you want to try it:&lt;br&gt;
&lt;a href="https://chromewebstore.google.com/detail/mofpdgjakcjgjdnbmgaikmhpdnaeaikj" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/mofpdgjakcjgjdnbmgaikmhpdnaeaikj&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’d be interested in hearing about websites where it behaves well, as well as the ones that expose something I missed.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>chromeextension</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A whiteboard that can't upload your drawing, because the browser won't let it</title>
      <dc:creator>pop3zxcv</dc:creator>
      <pubDate>Sun, 09 Aug 2026 17:07:56 +0000</pubDate>
      <link>https://dev.to/pop3_zxcv/a-whiteboard-that-cant-upload-your-drawing-because-the-browser-wont-let-it-302f</link>
      <guid>https://dev.to/pop3_zxcv/a-whiteboard-that-cant-upload-your-drawing-because-the-browser-wont-let-it-302f</guid>
      <description>&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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqerdbhgucnr1p862nlfv.png" 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%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqerdbhgucnr1p862nlfv.png" alt=" " width="800" height="751"&gt;&lt;/a&gt;&lt;br&gt;
I wanted to sketch a database schema last month and the first three whiteboards I opened asked me to sign in. So I wrote my own. While I was at it I decided the no-upload thing shouldn't be a line in a privacy policy, because you can't check a privacy policy.&lt;/p&gt;

&lt;p&gt;It's at &lt;a href="https://board.jamuny.com" rel="noopener noreferrer"&gt;board.jamuny.com&lt;/a&gt;. Below is what I hit building it.&lt;/p&gt;
&lt;h2&gt;
  
  
  One CSP directive does most of the work
&lt;/h2&gt;

&lt;p&gt;The page ships with this header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Content-Security-Policy: default-src 'self'; script-src 'self';
  img-src 'self' blob: data:; font-src 'self'; connect-src 'none';
  object-src 'none'; form-action 'none'; frame-ancestors 'none'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;connect-src 'none'&lt;/code&gt; blocks fetch, XMLHttpRequest, WebSocket, EventSource and sendBeacon at the browser level. If I got sloppy in six months and pasted in an analytics snippet, it wouldn't quietly start working. The browser would refuse the connection and log a policy violation.&lt;/p&gt;

&lt;p&gt;You can check this yourself in about thirty seconds: open devtools, draw a few shapes, watch the network panel stay empty.&lt;/p&gt;

&lt;p&gt;There's a Playwright test that fails if it ever stops being true:&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draws, switches boards and reloads without a single request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&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;requests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&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;requests&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;websocket&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&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;requests&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
  &lt;span class="c1"&gt;// draw, switch boards, reload, export PNG and SVG&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requests&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;isNotOwnAsset&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&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;One thing worth knowing if you write a test like this: &lt;code&gt;page.on('request')&lt;/code&gt; doesn't fire for WebSocket handshakes. I had the request listener alone for a while, and an actual WebSocket connection would have gone straight past it. You need &lt;code&gt;page.on('websocket')&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;The obvious cost is that there's no collaboration and there won't be. Multiplayer needs a server, a server means the strokes leave your machine, and then I've built the thing I was trying to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  No framework, for fairly boring reasons
&lt;/h2&gt;

&lt;p&gt;Astro emitting static HTML, plus a couple of &lt;code&gt;&amp;lt;script type="module"&amp;gt;&lt;/code&gt; islands. No React, no Svelte.&lt;/p&gt;

&lt;p&gt;This isn't asceticism about bundle size. A framework's job is reconciling a component tree against the DOM, and the DOM here is one &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; element that never changes. I'd be paying for hydration to manage a toolbar. Script weight sits around 23KB transferred, with a Lighthouse assertion that fails the build if it creeps up.&lt;/p&gt;

&lt;p&gt;There are two canvases stacked, actually. One holds the committed scene, the other holds whatever stroke you're currently drawing. Repaint the whole board on every &lt;code&gt;pointermove&lt;/code&gt; and it gets sluggish once there are a few hundred elements on it, so the overlay keeps an in-progress stroke down to one shape's worth of drawing.&lt;/p&gt;

&lt;h2&gt;
  
  
  roughjs draws a rectangle as four separate lines
&lt;/h2&gt;

&lt;p&gt;The board gives you Clean or Sketch per shape. Sketch runs through &lt;a href="https://roughjs.com/" rel="noopener noreferrer"&gt;roughjs&lt;/a&gt; for the hand-drawn wobble, and Clean originally did too, with roughness dialled to zero.&lt;/p&gt;

&lt;p&gt;Then I got a bug report saying the rectangles looked like "a line connecting four edges."&lt;/p&gt;

&lt;p&gt;I dumped the ops roughjs was producing for a rectangle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"move"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bcurveTo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"move"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bcurveTo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"move"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bcurveTo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"move"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bcurveTo"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four independent subpaths, one per edge. That's correct for a sketchy look, since each edge is meant to read as its own pencil stroke. The consequence is that there's no line join anywhere, because as far as the canvas API is concerned there's no corner, just four lines that happen to end near each other. At stroke width 2 nobody notices. At width 8 there's a chunk missing from every corner.&lt;/p&gt;

&lt;p&gt;Clean mode draws with plain canvas calls now (&lt;code&gt;ctx.rect()&lt;/code&gt;, &lt;code&gt;ctx.ellipse()&lt;/code&gt;, &lt;code&gt;moveTo&lt;/code&gt;/&lt;code&gt;lineTo&lt;/code&gt;), one &lt;code&gt;beginPath&lt;/code&gt;/&lt;code&gt;stroke&lt;/code&gt; per shape so &lt;code&gt;lineJoin&lt;/code&gt; applies. Sketch still uses roughjs, where none of this was ever a problem.&lt;/p&gt;

&lt;p&gt;There's a related one I only caught by opening an exported PNG. I was stroking an arrow's shaft and its head as a single path, so &lt;code&gt;ctx.setLineDash()&lt;/code&gt; applied to both. A dashed arrow came out with the head chopped into two or three dash fragments, and a dotted one barely had a head at all. On screen at normal zoom it reads as a slightly scruffy arrowhead, which is why I'd been looking at it for days without registering it. At 2x in an export it's obvious.&lt;/p&gt;

&lt;p&gt;Fixing it meant touching three places that all have to agree: the canvas renderer, the SVG exporter and the roughjs path. The roughjs one was the annoying bit. A Drawable's &lt;code&gt;options&lt;/code&gt; (including &lt;code&gt;strokeLineDash&lt;/code&gt;) are shared across every opset inside it, so a dashed shaft and a solid head can't be one Drawable however you build the path string. It has to return two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The style panel spent two revisions in the wrong place
&lt;/h2&gt;

&lt;p&gt;It started in the top toolbar. That row ended up carrying the tools plus seven stroke colours, five fills, four widths, Clean/Sketch, and Solid/Dashed/Dotted. Around twenty controls, which clipped off the right edge on a normal laptop window. You'd get &lt;code&gt;Clean | Sketch | Solid | D…&lt;/code&gt; and everything past that was gone.&lt;/p&gt;

&lt;p&gt;So I floated the panel over the canvas's top-left corner. That corner then stopped accepting pointer events, so you couldn't draw there. Nobody files a bug about this. They just feel like the app is slightly broken and don't come back.&lt;/p&gt;

&lt;p&gt;What shipped is a docked rail: a real flex column next to the canvas, with the canvas at &lt;code&gt;flex: 1; min-width: 0&lt;/code&gt;. Every canvas pixel is drawable because of how the layout is built, rather than because someone remembered to leave a gap.&lt;/p&gt;

&lt;p&gt;That created a layout shift problem, since the rail's width depends on what's selected. The fix was to stop doing it in JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.rail&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;#style-panel&lt;/span&gt;&lt;span class="nd"&gt;:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;])),&lt;/span&gt;
&lt;span class="nc"&gt;.rail&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;#app-menu&lt;/span&gt;&lt;span class="nd"&gt;:not&lt;/span&gt;&lt;span class="o"&gt;([&lt;/span&gt;&lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;]))&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;213px&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 script only toggles the &lt;code&gt;hidden&lt;/code&gt; attribute it was already toggling. The static markup ships in the same expanded state the script lands on a moment later, so there's no frame where the canvas box jumps. CLS measures 0.0000.&lt;/p&gt;

&lt;p&gt;One more that took me a while to see. Clicking an unselected shape starts a drag and populates the selection in the same &lt;code&gt;pointerdown&lt;/code&gt;. The next frame widens the rail, which shifts the canvas out from under a drag whose anchor was captured against the old edge, so the shape jumps sideways by the width of the rail while your finger hasn't moved. The rail is frozen for the duration of any live gesture now.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'd been using the wrong contrast standard
&lt;/h2&gt;

&lt;p&gt;The stroke palette started life as the syntax colours from another tool of mine, which are held to WCAG's 4.5:1 because they're text. Carried over to drawing strokes, that made everything dark and slightly muddy. The feedback I got was that the colours all looked pale, which was fair.&lt;/p&gt;

&lt;p&gt;4.5:1 is the figure for text. WCAG 1.4.11 asks 3:1 of non-text content, which is what a drawing stroke is. Moving to the correct bar is what made a bright palette possible at all, and I measured every value rather than relaxing the test until it passed.&lt;/p&gt;

&lt;p&gt;Two things went wrong doing it. First, white isn't the only background a stroke sits on, since you can draw on top of a filled shape. My new lime cleared 3:1 against the canvas and came in at 2.67 against the blue fill tint.&lt;/p&gt;

&lt;p&gt;The second cost me a deploy. My selection colour was resolved like this:&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="nx"&gt;STROKE_COLORS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blue&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The palette rewrite renamed &lt;code&gt;blue&lt;/code&gt; to &lt;code&gt;sky&lt;/code&gt;. &lt;code&gt;find&lt;/code&gt; returned &lt;code&gt;undefined&lt;/code&gt;, the &lt;code&gt;!&lt;/code&gt; kept TypeScript quiet, and &lt;code&gt;.value&lt;/code&gt; threw a TypeError at module load, so the whole island stopped booting. All 737 unit tests stayed green, because nothing imports that constant. What caught it was a Lighthouse assertion on &lt;code&gt;errors-in-console&lt;/code&gt;, which is the only gate I have that loads the real page in a real browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&gt;

&lt;p&gt;Pen, rectangle, ellipse, line, arrow and text. Select, move, resize, delete. Clean or Sketch per element, solid or dashed or dotted. Boards as tabs, stored in IndexedDB. Undo and redo per board. Infinite canvas with pan and zoom. PNG and SVG export.&lt;/p&gt;

&lt;p&gt;There's no account system, no sync, and no image tool yet.&lt;/p&gt;

&lt;p&gt;If you'd rather verify than believe me: devtools, draw something, watch the network panel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://board.jamuny.com" rel="noopener noreferrer"&gt;board.jamuny.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built on &lt;a href="https://github.com/steveruizok/perfect-freehand" rel="noopener noreferrer"&gt;perfect-freehand&lt;/a&gt; (MIT), &lt;a href="https://roughjs.com/" rel="noopener noreferrer"&gt;roughjs&lt;/a&gt; (MIT) and &lt;a href="https://github.com/jakearchibald/idb" rel="noopener noreferrer"&gt;idb&lt;/a&gt; (ISC).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>programming</category>
    </item>
    <item>
      <title>I got tired of reverse-engineering design systems by hand, so I built a Chrome extension</title>
      <dc:creator>pop3zxcv</dc:creator>
      <pubDate>Sat, 08 Aug 2026 11:24:05 +0000</pubDate>
      <link>https://dev.to/pop3_zxcv/i-got-tired-of-reverse-engineering-design-systems-by-hand-so-i-built-a-chrome-extension-48hk</link>
      <guid>https://dev.to/pop3_zxcv/i-got-tired-of-reverse-engineering-design-systems-by-hand-so-i-built-a-chrome-extension-48hk</guid>
      <description>&lt;p&gt;I spend a lot of time looking at how websites are put together. My usual process was slow: open DevTools, inspect a few elements, copy colors, check font sizes, look through media queries and write everything down somewhere.&lt;/p&gt;

&lt;p&gt;That process eventually became a Chrome extension.&lt;/p&gt;

&lt;p&gt;Design System Extractor reads the visual system of the page in your active tab and packages the results into a ZIP. Version 1.4.0 has now been published on the Chrome Web Store.&lt;/p&gt;

&lt;h3&gt;
  
  
  The basic workflow
&lt;/h3&gt;

&lt;p&gt;Open a page you are allowed to inspect, launch the extension and click Capture.&lt;/p&gt;

&lt;p&gt;The extension checks the page's DOM and styles for colors, typography, spacing, border radii, shadows, breakpoints, motion, contrast pairs and interaction states. It also looks for familiar components such as buttons, inputs, cards and badges.&lt;/p&gt;

&lt;p&gt;Before downloading, you can review the detected items and remove anything you do not want.&lt;/p&gt;

&lt;p&gt;The ZIP can contain JSON tokens, CSS custom properties, a Tailwind theme, Style Dictionary files and an inventory of the components found on the page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Screenshot capture was harder than expected
&lt;/h3&gt;

&lt;p&gt;Capturing a webpage sounds simple until the page has sticky headers, lazy-loaded sections, nested scrolling areas or an endless feed.&lt;/p&gt;

&lt;p&gt;I decided against generating one huge full-page screenshot. The extension finds useful sections, scrolls them into view and creates separate crops instead. Each crop can be checked before it enters the export.&lt;/p&gt;

&lt;p&gt;This also keeps endless pages under control. Capture has a hard stopping point rather than continuing to scroll through an infinite feed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to the page data
&lt;/h3&gt;

&lt;p&gt;Processing stays inside the extension on your computer.&lt;/p&gt;

&lt;p&gt;There is no backend and no analytics service. I do not receive the page URL, extracted styles, screenshots or usage information. Temporary viewport images are kept in memory only while the component crops are being made.&lt;/p&gt;

&lt;p&gt;The extension also avoids form values, passwords, cookies, authentication tokens and browser storage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try version 1.4.0
&lt;/h3&gt;

&lt;p&gt;The extension is available here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://chromewebstore.google.com/detail/gohgeagmchgejaecbdjnngcjjcgimlkm" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/gohgeagmchgejaecbdjnngcjjcgimlkm&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the first public release. I am particularly interested in pages where the capture misses a section, chooses a poor crop or extracts a token incorrectly. Those examples will help me decide what to fix next.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>chromeextension</category>
      <category>designsystem</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Your AI Agent Costs 6x More Than You Calculated</title>
      <dc:creator>pop3zxcv</dc:creator>
      <pubDate>Mon, 03 Aug 2026 18:27:06 +0000</pubDate>
      <link>https://dev.to/pop3_zxcv/why-your-ai-agent-costs-6x-more-than-you-calculated-3na9</link>
      <guid>https://dev.to/pop3_zxcv/why-your-ai-agent-costs-6x-more-than-you-calculated-3na9</guid>
      <description>&lt;p&gt;Pricing an LLM call is simple arithmetic. Input tokens times the input rate, plus output tokens times the output rate. Every pricing page shows you this, every calculator computes it, and for a chatbot it is correct.&lt;/p&gt;

&lt;p&gt;For an agent it is wrong by a factor of six, and the factor gets worse the longer the agent runs.&lt;/p&gt;

&lt;p&gt;The reason is not hidden or subtle. It follows from one property of language models that everybody knows and almost nobody puts into their cost estimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models have no memory
&lt;/h2&gt;

&lt;p&gt;A language model does not remember your last request. Each call is independent. If you want the model to know what happened three steps ago, you send it again.&lt;/p&gt;

&lt;p&gt;So an agent loop does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;step 1  send: system prompt + task
        get:  a tool call

step 2  send: system prompt + task + step 1 output + tool result
        get:  another tool call

step 3  send: system prompt + task + step 1 + step 2 + both tool results
        get:  another tool call
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take a concrete shape. A 2,000-token system prompt with tool schemas, a 500-token task, 400 tokens of model output per step, and 1,200 tokens of tool results per step. Here is what each step actually sends:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Context sent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2,500 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4,100 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;5,700 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each step costs more than the one before it, forever. Step 3 is more than twice the price of step 1 and it is doing the same amount of new work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula
&lt;/h2&gt;

&lt;p&gt;Across &lt;code&gt;N&lt;/code&gt; steps, every step pays for the system prompt and the task. That part is linear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N × (system + user)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The accumulated history is the part that hurts. Step &lt;code&gt;i&lt;/code&gt; carries the output and tool results of all &lt;code&gt;i − 1&lt;/code&gt; steps before it. Summing that over the whole run gives the triangular number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(output + tool_result) × N × (N − 1) / 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;total input tokens = N × (system + user) + (output + tool_result) × N × (N−1) / 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second term is quadratic. Double the step count and that part roughly quadruples.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that does at different step counts
&lt;/h2&gt;

&lt;p&gt;Same workload shape as above, no retries, no caching:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Steps&lt;/th&gt;
&lt;th&gt;Raw conversation&lt;/th&gt;
&lt;th&gt;Billed input&lt;/th&gt;
&lt;th&gt;Multiplier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;7,300&lt;/td&gt;
&lt;td&gt;12,300&lt;/td&gt;
&lt;td&gt;1.7×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;10,500&lt;/td&gt;
&lt;td&gt;28,500&lt;/td&gt;
&lt;td&gt;2.7×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;18,500&lt;/td&gt;
&lt;td&gt;97,000&lt;/td&gt;
&lt;td&gt;5.2×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;21,700&lt;/td&gt;
&lt;td&gt;135,600&lt;/td&gt;
&lt;td&gt;6.2×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;34,500&lt;/td&gt;
&lt;td&gt;354,000&lt;/td&gt;
&lt;td&gt;10.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;50,500&lt;/td&gt;
&lt;td&gt;771,000&lt;/td&gt;
&lt;td&gt;15.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;82,500&lt;/td&gt;
&lt;td&gt;2,085,000&lt;/td&gt;
&lt;td&gt;25.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;162,500&lt;/td&gt;
&lt;td&gt;8,170,000&lt;/td&gt;
&lt;td&gt;50.3×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Raw conversation" is the number you get if you add up everything the agent said and everything it read. It is the number your intuition reaches for. "Billed input" is what appears on the invoice.&lt;/p&gt;

&lt;p&gt;At 3 steps the gap is small enough to ignore. At 12 steps you are paying 6.2 times your estimate. At 50 steps, 25 times. A deep research agent that runs a hundred steps bills fifty times the tokens the conversation contains.&lt;/p&gt;

&lt;p&gt;Nothing is broken when this happens. It is what the pricing model does when you loop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries make it worse
&lt;/h2&gt;

&lt;p&gt;A failed tool call, malformed JSON, a guardrail rejection: whatever the cause, a retry re-sends the context too, at whatever depth the failure happened.&lt;/p&gt;

&lt;p&gt;At a 10% retry rate the 12-step example moves from 6.2× to 6.9×. Not dramatic on its own, but it stacks on top of a number that is already six times your estimate, and retry rates in production are rarely zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that move the bill
&lt;/h2&gt;

&lt;p&gt;Take the 12-step agent at a 10% retry rate on Claude Sonnet 5. Baseline is $0.351 per run. At 1,000 runs a day that is $10,534 a month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt caching.&lt;/strong&gt; Most of what you are paying for is context re-sent verbatim, and cache reads cost roughly 90% less than fresh input. A 90% hit rate takes the run to $0.109. That is 69% off, with the same model and the same agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step count.&lt;/strong&gt; Halving the loop from 12 steps to 6 takes it to $0.112, or 68% off. Almost identical to what caching bought you, which is worth sitting with for a second: removing half the reasoning steps and caching everything are about equally valuable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool result size.&lt;/strong&gt; Trimming tool results from 1,200 tokens to 400 takes it to $0.235, or 33% off. Smaller than the other two but easier than either. Every tool result is re-sent by every step that follows it, so truncating a verbose search result compounds down the whole run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterintuitive one
&lt;/h2&gt;

&lt;p&gt;The instinct when a bill is too high is to switch to a cheaper model. Compare that against cutting steps, on a 20-step agent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Cost per run&lt;/th&gt;
&lt;th&gt;Saving&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline: Sonnet 5, 20 steps&lt;/td&gt;
&lt;td&gt;$0.867&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Swap to Haiku 4.5, still 20 steps&lt;/td&gt;
&lt;td&gt;$0.433&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stay on Sonnet 5, cut to 10 steps&lt;/td&gt;
&lt;td&gt;$0.257&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten steps on the frontier model is cheaper than twenty steps on the small one, and you keep the better model.&lt;/p&gt;

&lt;p&gt;This is not a quirk of these two models. Price is linear: a model at half the rate costs half as much, and that is the ceiling on what switching can buy you. Step count is superlinear, so halving it saves more than half. The gap widens as the agent gets longer.&lt;/p&gt;

&lt;p&gt;Which means the first question about an expensive agent is not "what cheaper model could do this", it is "why does this take twenty steps".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why most calculators miss it, and who does not
&lt;/h2&gt;

&lt;p&gt;Nearly every LLM pricing calculator prices one request. Input times rate, plus output times rate. That is the right model for a chat completion and it is what the pricing pages describe, so it is a reasonable thing to build.&lt;/p&gt;

&lt;p&gt;Two tools go further and deserve naming.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kenodo.com/tools/ai-agent-cost-calculator" rel="noopener noreferrer"&gt;Kenodo's agent calculator&lt;/a&gt; models the same cumulative context this post describes, and says so directly: input tokens for step N include the system prompt plus every prior output and tool response. It covers eight models with a cache-hit slider. If you want a second opinion on your numbers, run them there too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://softcery.com/ai-voice-agents-calculator" rel="noopener noreferrer"&gt;Softcery's voice agent calculator&lt;/a&gt; applies a flat 1.8x "reality factor" and notes in a footnote that conversation history "compounds O(n²) with turns". For voice at roughly four turns a minute with short turns, a constant is a fair approximation, and theirs also absorbs function-calling round trips and barge-in handling.&lt;/p&gt;

&lt;p&gt;Tool-using agents sit elsewhere on the curve. Turns are fewer but each drags a large tool result behind it, so the quadratic term takes over earlier. A constant fitted to a voice call will not fit a research loop, which is the argument for computing the curve rather than picking a number.&lt;/p&gt;

&lt;p&gt;The gaps I still wanted filled: a retry rate, since failures resend context too and nobody runs at a 0% failure rate. Per-step accumulation, so you can see where the curve bends instead of only the total. And a source URL with a verification date behind every rate, because provider pricing moves constantly and a calculator with stale numbers is worse than none.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this arithmetic does not tell you
&lt;/h2&gt;

&lt;p&gt;Worth being clear about the edges.&lt;/p&gt;

&lt;p&gt;Retries here are a flat multiplier on the total. Real retries happen at a specific depth, so a failure at step 18 costs far more than one at step 2, and a flat rate under-counts late failures.&lt;/p&gt;

&lt;p&gt;Cache hit rate is one number. In reality your system prompt might cache at 99% while your tool results never cache at all.&lt;/p&gt;

&lt;p&gt;Providers tokenize differently, so comparing token counts across vendors is approximate. And cache writes are not free everywhere: OpenAI's GPT-5.6 charges 1.25× uncached input to write to cache, which the numbers above do not include.&lt;/p&gt;

&lt;p&gt;None of this changes the shape of the curve. It does mean you should treat any figure here as a planning estimate rather than a billing forecast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it on your own numbers
&lt;/h2&gt;

&lt;p&gt;I built a calculator that models the loop instead of a single request: step count, tool result size, retry rate, cache hit rate, across 17 models, with the per-step accumulation and cost attribution broken out. Free, no signup, runs entirely in the browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://costperrun.com" rel="noopener noreferrer"&gt;costperrun.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The pricing data is &lt;a href="https://github.com/pop3zxcv/costperrun" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt; with a source URL and verification date against every rate. If you find a stale price, open an issue.&lt;/p&gt;

&lt;p&gt;The number worth checking first is your step count. It is almost always higher than you think, and it is the term that squares.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
