<?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: Sherotree</title>
    <description>The latest articles on DEV Community by Sherotree (@sherotree).</description>
    <link>https://dev.to/sherotree</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%2F873577%2Fa596bd86-49ed-445b-bc37-0f7cd0c5e93a.jpg</url>
      <title>DEV Community: Sherotree</title>
      <link>https://dev.to/sherotree</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sherotree"/>
    <language>en</language>
    <item>
      <title>SVG to PNG in the tab when the design export is vector-only</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Tue, 03 Nov 2026 15:34:09 +0000</pubDate>
      <link>https://dev.to/sherotree/svg-png-in-the-tab-when-the-design-export-is-vector-only-590b</link>
      <guid>https://dev.to/sherotree/svg-png-in-the-tab-when-the-design-export-is-vector-only-590b</guid>
      <description>&lt;p&gt;Design drops an SVG. Slack wants a PNG. The App Store listing wants a PNG. Email clients laugh at SVG. Your CI screenshot fixture wants a PNG. And you're on Linux, so "just Open in Preview → Export" is not a thing.&lt;/p&gt;

&lt;p&gt;I hit this weekly. The vector file is correct — scale, colors, viewBox all fine — but the consumer only eats raster. Opening Illustrator (or waiting for design to re-export at 1x/2x/3x) for a one-off PNG is the slow path.&lt;/p&gt;

&lt;p&gt;So I keep a tab workflow: SVG in → PNG out, without uploading the mark to a random converter.&lt;/p&gt;

&lt;h2&gt;
  
  
  When SVG alone isn't enough
&lt;/h2&gt;

&lt;p&gt;SVG is great inside the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Icons that follow &lt;code&gt;currentColor&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Responsive logos that stay crisp&lt;/li&gt;
&lt;li&gt;Inline chrome that never blur on retina&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PNG still wins outside the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chat / docs&lt;/strong&gt; — Slack, Notion, Linear comments often strip or refuse SVG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketing surfaces&lt;/strong&gt; — many email clients, some CMS uploaders, OG image pipelines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stores &amp;amp; listings&lt;/strong&gt; — App Store / Play / directory forms that hard-require PNG/JPG&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff fixtures&lt;/strong&gt; — visual regression baselines are almost always raster&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partner handoff&lt;/strong&gt; — "send me the logo" usually means a transparent PNG, not XML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the source as SVG and deriving PNG at the moment of need beats maintaining a parallel PNG folder that drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes wrong with the naive paths
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ask design for another export&lt;/strong&gt; — fine for a brand refresh; painful for "I need a 512px transparent mark before standup."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Desktop converters&lt;/strong&gt; — ImageMagick / Inkscape / Affinity work, until you're on a locked laptop, a hotel Wi‑Fi machine, or someone else's Chromebook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upload-to-convert sites&lt;/strong&gt; — easiest to regret. Brand SVGs are IP. I don't want the mark sitting in someone else's temp bucket because I needed a PNG for a README.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshot the SVG in the browser&lt;/strong&gt; — you get wrong DPI, wrong background, wrong crop, and a JPEG-looking mess more often than you admit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually need from the convert step
&lt;/h2&gt;

&lt;p&gt;For frontend / DX use, the checklist is boring and specific:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transparent background&lt;/strong&gt; by default (don't bake white unless I ask)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit pixel size&lt;/strong&gt; — 128 / 256 / 512 / 1024, not "whatever the viewBox felt like"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No upload&lt;/strong&gt; — file stays in the tab (DevTools Offline litmus still applies)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictable naming&lt;/strong&gt; — &lt;code&gt;logo-512.png&lt;/code&gt;, not &lt;code&gt;download (17).png&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity preview&lt;/strong&gt; before download — catch missing fonts / clipped paths early&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the tool can't do those five, I'm back to Inkscape CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tab that fits the handoff
&lt;/h2&gt;

&lt;p&gt;When design already gave me the SVG and I just need raster for a non-SVG surface, I've used &lt;a href="https://www.uwarp.design/svg-to-png" rel="noopener noreferrer"&gt;Uwarp's SVG → PNG tool&lt;/a&gt; — drop the file, pick a size, download PNG in the browser. Worth trying when the alternative is another desktop install or an upload converter you can't audit.&lt;/p&gt;

&lt;p&gt;For a recurring pipeline (favicon set, store assets, email header), script it: &lt;code&gt;rsvg-convert&lt;/code&gt;, &lt;code&gt;inkscape --export-type=png&lt;/code&gt;, or a tiny Puppeteer render. The tab is for the one-offs that shouldn't block a PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the SVG as source of truth
&lt;/h2&gt;

&lt;p&gt;A few rules that stopped me from growing a junk drawer of PNGs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commit SVG&lt;/strong&gt; in the design-system / brand repo; generate PNG at publish time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name by intent + size&lt;/strong&gt; (&lt;code&gt;mark-on-dark-512.png&lt;/code&gt;), never &lt;code&gt;final_final2.png&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't "edit" the PNG&lt;/strong&gt; — if color changes, change the SVG and re-export&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the sizes you actually ship&lt;/strong&gt; (email 600w, OG 1200×630, store 1024) so people stop inventing new ones in Slack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SVG → PNG is a packaging step, not a design decision. Treat it like that and the folder stays boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiny habit
&lt;/h2&gt;

&lt;p&gt;Next time someone asks for "the logo as PNG" and you already have the SVG, don't reopen the design tool and don't upload the brand mark. Convert in a local-ish tab (or a one-liner CLI), verify transparency and size, send the file, close the loop.&lt;/p&gt;

&lt;p&gt;Fewer blocked handoffs. Fewer mystery PNGs in Downloads. Same vector source — just the raster shape the other surface actually accepts.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>dx</category>
    </item>
    <item>
      <title>Tiny icons as data URIs — skip another network hop for chrome assets</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:33:38 +0000</pubDate>
      <link>https://dev.to/sherotree/tiny-icons-as-data-uris-skip-another-network-hop-for-chrome-assets-41jj</link>
      <guid>https://dev.to/sherotree/tiny-icons-as-data-uris-skip-another-network-hop-for-chrome-assets-41jj</guid>
      <description>&lt;p&gt;Chrome UI is full of tiny assets that should never fight the critical path: a 16×16 chevron, a mute glyph, a spinner frame, a toolbar mark that shows up before the rest of the page finishes deciding who you are.&lt;/p&gt;

&lt;p&gt;I still see those shipped as separate &lt;code&gt;/icons/*.svg&lt;/code&gt; or &lt;code&gt;/icons/*.png&lt;/code&gt; requests. On a good day they ride HTTP/2 multiplexing and you barely notice. On a cold CDN edge, a flaky hotel Wi‑Fi, or a settings drawer that mounts before the icon sprite is warm, you get the classic flash of empty boxes.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;chrome&lt;/strong&gt; icons — not hero art, not user avatars — I default to inlining as data URIs more often than I used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I mean by "chrome assets"
&lt;/h2&gt;

&lt;p&gt;Not the marketing hero. Not a product screenshot. The boring bits that make the shell feel finished:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sidebar / toolbar glyphs under ~2–4 KB each&lt;/li&gt;
&lt;li&gt;Empty-state illustrations that are basically icons with padding&lt;/li&gt;
&lt;li&gt;Status dots and badges that are vector or tiny PNG&lt;/li&gt;
&lt;li&gt;Favicon-sized marks reused inside the app chrome&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an asset is decorative, tiny, and shown before meaningful paint, a network hop is a tax. If it's large, user-generated, or rarely shown, leave it as a normal URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network hop you forget about
&lt;/h2&gt;

&lt;p&gt;A separate icon URL costs more than bytes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Priority / queue&lt;/strong&gt; — competes with CSS, fonts, and your first API call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache miss on first visit&lt;/strong&gt; — every new deploy with hashed filenames resets the miss&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CORS / CSP footguns&lt;/strong&gt; — SVG-as-&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; vs inline SVG vs background-image all behave differently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout flash&lt;/strong&gt; — width/height reserved late → CLS or empty toolbar buttons&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Data URIs don't magically shrink bytes (base64 costs ~33%), but they &lt;strong&gt;remove a round trip&lt;/strong&gt; and make the asset travel with the CSS/JS that needs it. For a 600-byte SVG that's often the right trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  When data URIs win (and when they don't)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prefer data URI when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The icon is ≤ ~2–3 KB uncompressed (SVG preferred)&lt;/li&gt;
&lt;li&gt;It appears in first paint / shell / authenticated chrome&lt;/li&gt;
&lt;li&gt;You're already shipping a CSS or JS module that owns that surface&lt;/li&gt;
&lt;li&gt;You don't need the browser to cache the same glyph across many unrelated pages independently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prefer a normal URL (or sprite) when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The asset is large or shared across many independent entry points&lt;/li&gt;
&lt;li&gt;You need CDN edge caching and long-lived immutable URLs&lt;/li&gt;
&lt;li&gt;Design iterates the icon weekly and you don't want CSS churn&lt;/li&gt;
&lt;li&gt;You're shipping dozens of icons and a sprite / icon font / SVG sprite sheet is cleaner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't inline a 40 KB illustration. I do inline the 800-byte "copy" glyph next to every code block toolbar.&lt;/p&gt;

&lt;h2&gt;
  
  
  A workflow that doesn't fight the design handoff
&lt;/h2&gt;

&lt;p&gt;Design still exports PNG/SVG. Engineering still needs a string you can paste into CSS:&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;.toolbar-copy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-image&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="s1"&gt;"data:image/svg+xml,..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;background-size&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;16px&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;or:&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;COPY_ICON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data:image/svg+xml,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&amp;lt;svg xmlns="http://www.w3.org/2000/svg" ...&amp;gt;...&amp;lt;/svg&amp;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 friction is converting the export into a safe data URI without opening three tabs and hoping the encoder didn't mangle &lt;code&gt;#&lt;/code&gt; / quotes.&lt;/p&gt;

&lt;p&gt;Checklist I use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prefer &lt;strong&gt;SVG&lt;/strong&gt; over PNG when the mark is flat UI chrome&lt;/li&gt;
&lt;li&gt;Strip editor junk (&lt;code&gt;id&lt;/code&gt;, unused &lt;code&gt;defs&lt;/code&gt;, Illustrator cruft) before encoding&lt;/li&gt;
&lt;li&gt;Prefer &lt;code&gt;encodeURIComponent&lt;/code&gt; for SVG (not base64) so the string stays readable in diffs&lt;/li&gt;
&lt;li&gt;Cap size — if the data URI is longer than the component that uses it, reconsider&lt;/li&gt;
&lt;li&gt;Keep width/height in CSS so you don't rely on intrinsic size for layout&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Converting without another upload site
&lt;/h2&gt;

&lt;p&gt;I don't want to POST a brand mark to a random "image to base64" site just to get a &lt;code&gt;data:&lt;/code&gt; string. Same Offline litmus as other one-off transforms: if convert dies with DevTools Offline, it wasn't local enough for me.&lt;/p&gt;

&lt;p&gt;When I need a quick tab for this, I've used &lt;a href="https://www.uwarp.design/image-to-datauri" rel="noopener noreferrer"&gt;Uwarp's image → data URI tool&lt;/a&gt; — drop the icon, get a data URI, paste into CSS/JS. Worth trying when the alternative is a desktop converter or an upload encoder you can't audit.&lt;/p&gt;

&lt;p&gt;Roll your own if this is daily: a tiny Node script with &lt;code&gt;fs.readFileSync&lt;/code&gt; + &lt;code&gt;encodeURIComponent&lt;/code&gt; is fine. The point is the &lt;strong&gt;habit&lt;/strong&gt; (inline tiny chrome) more than the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't turn this into a religion
&lt;/h2&gt;

&lt;p&gt;A few failure modes I hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inlining everything&lt;/strong&gt; → CSS balloons; first paint gets worse, not better&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base64 PNG for every glyph&lt;/strong&gt; → you paid the 33% tax and still ship raster blur on retina&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inlining user avatars&lt;/strong&gt; → privacy + size disaster; never do this&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting dark mode&lt;/strong&gt; → one data URI, one color; prefer &lt;code&gt;currentColor&lt;/code&gt; SVG or dual tokens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: if I can't justify the icon as "shell chrome under a few KB," it stays a URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiny habit
&lt;/h2&gt;

&lt;p&gt;Next time you add a toolbar button and reach for &lt;code&gt;/assets/icons/copy.svg&lt;/code&gt;, ask: is this a critical-path chrome glyph? If yes, inline it as a data URI (SVG + &lt;code&gt;encodeURIComponent&lt;/code&gt; preferred). If no, leave the network hop alone.&lt;/p&gt;

&lt;p&gt;Fewer empty boxes on first paint. Fewer "why is the mute icon late?" threads. Same design file — just a better shipping shape for the tiny stuff.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>dx</category>
    </item>
    <item>
      <title>Record a short repro for the agent instead of describing the bug in Slack</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:54:13 +0000</pubDate>
      <link>https://dev.to/sherotree/record-a-short-repro-for-the-agent-instead-of-describing-the-bug-in-slack-2o86</link>
      <guid>https://dev.to/sherotree/record-a-short-repro-for-the-agent-instead-of-describing-the-bug-in-slack-2o86</guid>
      <description>&lt;p&gt;I’ve lost count of how many Slack threads start with “the settings page is weird on save” and end with three people guessing which field, which browser, and whether it’s staging or prod. Then someone pastes a paragraph of vibes into the coding agent and wonders why the patch misses.&lt;/p&gt;

&lt;p&gt;Agents don’t need your narrative. They need a &lt;strong&gt;short, replayable repro&lt;/strong&gt; — something they can open, run, or watch once and map onto files. Describing the bug in chat is the slow path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “short repro” means (under ~90 seconds)
&lt;/h2&gt;

&lt;p&gt;Not a full Loom walkthrough. Not a five-minute screen share. One of these, whichever is cheapest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Recorded steps&lt;/strong&gt; — numbered clicks + expected vs actual (fits in a comment)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failing URL + query/state&lt;/strong&gt; — deep link with the bad filter, locale, or feature flag already on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal HAR / network snippet&lt;/strong&gt; — the one 500 that matters, not the whole waterfall&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10–30s screen recording&lt;/strong&gt; — only if timing / animation / hover is the bug&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Console + stack&lt;/strong&gt; — the red line and the first app frame, not a wall of React warnings&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it takes longer to &lt;em&gt;make&lt;/em&gt; the repro than to &lt;em&gt;guess&lt;/em&gt; twice, you’re overbuilding. Cap it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Slack description tax
&lt;/h2&gt;

&lt;p&gt;A typical thread looks like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“User said save doesn’t work. I tried once, seemed fine. Maybe race? Can someone check?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That message is useful for humans coordinating. It’s garbage input for an agent. There’s no route, no payload, no browser, no “works on /settings?tab=billing but fails on /settings?tab=team”.&lt;/p&gt;

&lt;p&gt;I treat Slack as the &lt;strong&gt;routing&lt;/strong&gt; layer (“who owns this / is it Sev?”) and the agent chat / PR as the &lt;strong&gt;artifact&lt;/strong&gt; layer. Don’t merge those jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A repro pack I actually paste
&lt;/h2&gt;

&lt;p&gt;I keep a stub next to bug triage so I’m not inventing format every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Repro for agent&lt;/span&gt;

&lt;span class="gs"&gt;**Surface:**&lt;/span&gt; Settings → Team members  
&lt;span class="gs"&gt;**Env:**&lt;/span&gt; staging, Chrome 130, logged in as org-admin  
&lt;span class="gs"&gt;**URL:**&lt;/span&gt; https://staging.example.com/settings/team?invite=open  
&lt;span class="gs"&gt;**Steps:**&lt;/span&gt;
&lt;span class="p"&gt;1.&lt;/span&gt; Open invite modal
&lt;span class="p"&gt;2.&lt;/span&gt; Enter email already in org
&lt;span class="p"&gt;3.&lt;/span&gt; Click Send invite
&lt;span class="gs"&gt;**Expected:**&lt;/span&gt; inline error “Already a member”
&lt;span class="gs"&gt;**Actual:**&lt;/span&gt; spinner forever; network tab shows POST /invites → 409 with body &lt;span class="sb"&gt;`{ "code": "duplicate" }`&lt;/span&gt;; UI never reads &lt;span class="sb"&gt;`code`&lt;/span&gt;
&lt;span class="gs"&gt;**Artifacts:**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; console: &lt;span class="sb"&gt;`Unhandled rejection: InviteError`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; optional: 18s recording &lt;span class="sb"&gt;`repro-invite-409.mp4`&lt;/span&gt; (only if steps unclear)
&lt;span class="gs"&gt;**Out of scope:**&lt;/span&gt; redesign modal, change API status codes
&lt;span class="gs"&gt;**Ask:**&lt;/span&gt; smallest UI fix — map 409/duplicate to the existing error component
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pack is boring on purpose. Agents thrive on boring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why recordings beat paragraphs (sometimes)
&lt;/h2&gt;

&lt;p&gt;When the bug is &lt;em&gt;timing&lt;/em&gt; — toast that flashes, focus that jumps, drag that drops a pixel early — prose fails. A 15-second clip with the mouse visible beats five Slack messages.&lt;/p&gt;

&lt;p&gt;Rules I use so recordings stay useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start already on the broken screen (no login theater)&lt;/li&gt;
&lt;li&gt;One take; don’t narrate a podcast over it&lt;/li&gt;
&lt;li&gt;Name the file with the symptom (&lt;code&gt;invite-spinner-409.mp4&lt;/code&gt;), not &lt;code&gt;screen-recording-tuesday&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Still write the Expected/Actual lines — video alone is slow to index&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the bug is a clear 409 and a missing branch, skip the video. Steps + network row win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into the agent workflow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before asking for a fix:&lt;/strong&gt; drop the repro pack (and file paths if you know them).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constraint line:&lt;/strong&gt; “touch only the invite modal + its error mapper; no API changes.”
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance:&lt;/strong&gt; “show me the branch that handles &lt;code&gt;code === 'duplicate'&lt;/code&gt; and a one-line test or Storybook state.”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A prompt I reuse:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here’s a short repro. Don’t ask clarifying questions you can answer from the pack. Propose the smallest patch and name the files you’ll change.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Agents that can &lt;em&gt;see&lt;/em&gt; the failure waste less of your review time inventing theories.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pasting “it’s broken” into the agent and hoping
&lt;/li&gt;
&lt;li&gt;Screenshot-only reports with no URL or status code
&lt;/li&gt;
&lt;li&gt;Long voice notes that never become steps
&lt;/li&gt;
&lt;li&gt;Letting the agent “explore the settings area” without a target state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Exploration without a repro is how you get drive-by refactors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiny habit, big DX win
&lt;/h2&gt;

&lt;p&gt;Next time you’re about to type a paragraph in Slack &lt;em&gt;and&lt;/em&gt; into the agent, stop. Spend ninety seconds on a repro pack. Route ownership in Slack; feed artifacts to the agent.&lt;/p&gt;

&lt;p&gt;You’ll get smaller diffs, fewer “works on my machine” loops, and fewer threads that exist only to reconstruct what one recording would have shown.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>debugging</category>
      <category>dx</category>
    </item>
    <item>
      <title>Prompt packs for empty / error / loading states — stop shipping happy-path only</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Tue, 03 Nov 2026 11:25:49 +0000</pubDate>
      <link>https://dev.to/sherotree/prompt-packs-for-empty-error-loading-states-stop-shipping-happy-path-only-i48</link>
      <guid>https://dev.to/sherotree/prompt-packs-for-empty-error-loading-states-stop-shipping-happy-path-only-i48</guid>
      <description>&lt;p&gt;Agents are weirdly good at the happy path. Give them a Figma frame of a filled dashboard and you get a filled dashboard. Give them a “build a projects list” prompt and you get rows, avatars, and a neat toolbar.&lt;/p&gt;

&lt;p&gt;What you usually &lt;em&gt;don’t&lt;/em&gt; get: the empty list on day one, the 500 that lands mid-scroll, or the skeleton that doesn’t jump when real data arrives. Those states are where users decide whether the product feels finished — and they’re the first thing agents skip unless you ask for them on purpose.&lt;/p&gt;

&lt;p&gt;So I stopped describing “the screen” and started shipping &lt;strong&gt;prompt packs&lt;/strong&gt; for the three non-happy states every surface needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: one pack per surface, four stories minimum
&lt;/h2&gt;

&lt;p&gt;For any list / detail / settings panel the agent touches, I keep a tiny markdown pack next to the feature (or in the PR description). Four required stories:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Happy&lt;/strong&gt; — data present, primary actions enabled
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty&lt;/strong&gt; — zero rows / first-run / filtered-to-nothing (these are different)
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error&lt;/strong&gt; — failed fetch, partial failure, retry affordance
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loading&lt;/strong&gt; — initial load &lt;em&gt;and&lt;/em&gt; refetch / pagination (also different)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the agent only returns #1, the PR is incomplete. Same bar as “missing tests.”&lt;/p&gt;

&lt;p&gt;Example pack stub I paste into the agent chat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Surface: Projects list (`ProjectsPage`)&lt;/span&gt;

Implement ALL four states before considering this done.

&lt;span class="gu"&gt;### Happy&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; 3–12 projects; name, updatedAt, owner avatar
&lt;span class="p"&gt;-&lt;/span&gt; Primary CTA: New project (top-right)
&lt;span class="p"&gt;-&lt;/span&gt; Row click → project detail

&lt;span class="gu"&gt;### Empty (three variants — do all)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; First-run: zero projects ever → illustration + “Create your first project”
&lt;span class="p"&gt;-&lt;/span&gt; Filtered empty: filters active, zero matches → “No projects match” + Clear filters
&lt;span class="p"&gt;-&lt;/span&gt; Archived-only: toggle shows archived but none → short copy, no big empty art

&lt;span class="gu"&gt;### Error&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; List fetch failed → inline error in list region, Retry button, keep chrome
&lt;span class="p"&gt;-&lt;/span&gt; Do NOT full-page crash the app shell
&lt;span class="p"&gt;-&lt;/span&gt; Preserve last-known list if refetch fails (stale-while-error)

&lt;span class="gu"&gt;### Loading&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Initial: skeleton rows matching final layout (same columns / gutters)
&lt;span class="p"&gt;-&lt;/span&gt; Refetch: subtle top progress or opacity — do NOT remount full-page spinner
&lt;span class="p"&gt;-&lt;/span&gt; No layout shift when data lands (reserve avatar + title width)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pack is boring on purpose. Agents love inventing a clever empty illustration and forgetting Clear filters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why “empty” needs variants
&lt;/h2&gt;

&lt;p&gt;“Empty state” is not one design. First-run is marketing-adjacent. Filtered-empty is a recovery path. Permission-empty (“you can’t see any projects yet”) is a different CTA entirely.&lt;/p&gt;

&lt;p&gt;If you only say “add an empty state,” the model picks the prettiest one and ships it for every case. Then support gets “I filtered by tag and the whole page turned into onboarding.”&lt;/p&gt;

&lt;p&gt;Name the variants in the pack. Force the agent to emit separate components or clear branches:&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;type&lt;/span&gt; &lt;span class="nx"&gt;ListPhase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;initial&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refetch&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;first-run&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filtered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;archived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;forbidden&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Project&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;I ask the agent to &lt;em&gt;type&lt;/em&gt; the phase union first. The UI has somewhere to hang after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error states agents keep getting wrong
&lt;/h2&gt;

&lt;p&gt;Common agent mistakes I reject in review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full-page error that unmounts the nav
&lt;/li&gt;
&lt;li&gt;Toast-only failure with an infinite spinner underneath
&lt;/li&gt;
&lt;li&gt;Retry that re-runs the whole route loader and wipes scroll
&lt;/li&gt;
&lt;li&gt;Copy that blames the user (“Something went wrong. Try again later.”) with no Retry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the pack I spell the contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Error contract&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Error UI lives &lt;span class="ge"&gt;*inside*&lt;/span&gt; the list landmark (&lt;span class="sb"&gt;`role="region"`&lt;/span&gt; / labelled heading)
&lt;span class="p"&gt;-&lt;/span&gt; Retry calls the same query fn; disable button while in flight
&lt;span class="p"&gt;-&lt;/span&gt; If &lt;span class="sb"&gt;`stale`&lt;/span&gt; exists, show dimmed list + banner — don’t blank the page
&lt;span class="p"&gt;-&lt;/span&gt; Log &lt;span class="sb"&gt;`error.message`&lt;/span&gt; for support; never render raw stack to the user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Agents follow contracts better than vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading without the layout jump
&lt;/h2&gt;

&lt;p&gt;Happy-path demos hide CLS. Real users notice when the toolbar drops 40px after load.&lt;/p&gt;

&lt;p&gt;I put explicit anti-shift rules in the loading section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Loading anti-shift&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Skeleton row height === real row height (measure once, hardcode)
&lt;span class="p"&gt;-&lt;/span&gt; Keep page title + primary CTA mounted during initial load
&lt;span class="p"&gt;-&lt;/span&gt; Prefer &lt;span class="sb"&gt;`content-visibility`&lt;/span&gt; / fixed skeleton count over “spinner in a void”
&lt;span class="p"&gt;-&lt;/span&gt; Refetch must not replace the list with a centered spinner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the design system already has &lt;code&gt;&amp;lt;Skeleton&amp;gt;&lt;/code&gt;, tell the agent which one — otherwise it invents a new shimmer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I wire this into the agent workflow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before codegen:&lt;/strong&gt; paste the pack (or &lt;code&gt;@&lt;/code&gt;-mention &lt;code&gt;docs/prompt-packs/projects-list.md&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance:&lt;/strong&gt; “PR must include Storybook stories or Playwright cases for empty/error/loading, not only happy.”
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review checklist:&lt;/strong&gt; open the four states in Storybook before reading the diff. If empty looks like happy with &lt;code&gt;items=[]&lt;/code&gt; and no CTA, bounce it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A one-liner that saves me hours:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don’t merge agent UI that only has a happy path Storybook story.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Minimal Storybook set I ask for
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Projects/List&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProjectsList&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Happy&lt;/span&gt; &lt;span class="o"&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectsList&lt;/span&gt; &lt;span class="na"&gt;phase&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mockProjects&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FirstRun&lt;/span&gt; &lt;span class="o"&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectsList&lt;/span&gt; &lt;span class="na"&gt;phase&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;first-run&lt;/span&gt;&lt;span class="dl"&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;gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FilteredEmpty&lt;/span&gt; &lt;span class="o"&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectsList&lt;/span&gt; &lt;span class="na"&gt;phase&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filtered&lt;/span&gt;&lt;span class="dl"&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;gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LoadError&lt;/span&gt; &lt;span class="o"&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="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectsList&lt;/span&gt; &lt;span class="na"&gt;phase&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;503&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;InitialLoading&lt;/span&gt; &lt;span class="o"&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="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectsList&lt;/span&gt; &lt;span class="na"&gt;phase&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;initial&lt;/span&gt;&lt;span class="dl"&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;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five stories beats a paragraph of “please handle edge cases.”&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed after we did this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fewer “looks done in staging, broken for new orgs” launches
&lt;/li&gt;
&lt;li&gt;Reviewers stop arguing about taste and point at a missing story
&lt;/li&gt;
&lt;li&gt;Agents stop optimizing only for the screenshot we pasted from Figma
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy path is still the demo. Empty / error / loading are the product. Put them in the prompt pack up front, or you’ll keep shipping demos.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>ux</category>
      <category>react</category>
    </item>
    <item>
      <title>Feature-flag AI-suggested UI before it hits every user</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Mon, 02 Nov 2026 15:32:22 +0000</pubDate>
      <link>https://dev.to/sherotree/feature-flag-ai-suggested-ui-before-it-hits-every-user-31e</link>
      <guid>https://dev.to/sherotree/feature-flag-ai-suggested-ui-before-it-hits-every-user-31e</guid>
      <description>&lt;p&gt;The first time we shipped an agent-written settings panel straight to &lt;code&gt;main&lt;/code&gt;, it looked fine in staging. Then support got three tickets in an hour: a toggle that did nothing for free plans, a “Save” that double-fired on slow networks, and a new empty state that hid the only CTA half our users needed.&lt;/p&gt;

&lt;p&gt;CI was green. Visuals looked close. The bug was product judgment the model never had — and we gave 100% of traffic that judgment on day one.&lt;/p&gt;

&lt;p&gt;So we stopped treating “agent UI merged” as “agent UI live.” Feature flags became the default release valve for anything an agent touched in the UI layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode: merge = rollout
&lt;/h2&gt;

&lt;p&gt;Agent PRs tempt you to skip the usual caution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Diff is “just UI”&lt;/li&gt;
&lt;li&gt;Screenshots look right&lt;/li&gt;
&lt;li&gt;Playwright clicked through the happy path&lt;/li&gt;
&lt;li&gt;Reviewer approved the code shape&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that answers: does this layout work for the cohort that never sees premium fields? Does the new empty state break the onboarding funnel? Does the regenerated form still respect the old keyboard path power users rely on?&lt;/p&gt;

&lt;p&gt;Those are traffic questions. Feature flags are how you ask them without a full revert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I flag (and what I don’t)
&lt;/h2&gt;

&lt;p&gt;I flag &lt;strong&gt;user-visible surfaces the agent authored or heavily rewrote&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New panels, modals, empty / error / loading states&lt;/li&gt;
&lt;li&gt;Regenerated forms and settings sections&lt;/li&gt;
&lt;li&gt;Agent-refactored navigation / IA changes&lt;/li&gt;
&lt;li&gt;Copy-heavy UI where tone or CTA placement shifted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I usually &lt;strong&gt;don’t&lt;/strong&gt; flag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pure token / lint / type cleanup with no layout change&lt;/li&gt;
&lt;li&gt;Test-only or Storybook-only diffs&lt;/li&gt;
&lt;li&gt;Internal admin tools with a tiny known audience (still stage them; just don’t burn a flag name)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: if a stranger on the free plan could hit it tomorrow, it gets a flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default pattern we use
&lt;/h2&gt;

&lt;p&gt;Keep it boring. One boolean (or percentage) per agent UI surface, named after the ticket, not the model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// flags.ts — names are product, not “gpt-panel-v3”&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;settingsBillingV2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;booleanFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings-billing-v2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;emptyProjectsAgentCopy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;booleanFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty-projects-agent-copy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the route / component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SettingsBilling&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;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings-billing-v2&lt;/span&gt;&lt;span class="dl"&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;enabled&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsBillingLegacy&lt;/span&gt; &lt;span class="p"&gt;/&amp;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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsBillingV2&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt; &lt;span class="c1"&gt;// agent-authored surface&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kill switch lives next to the flag definition, not in a Slack thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ops: set settings-billing-v2 = false → instant revert without redeploy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your stack is LaunchDarkly / Unleash / Statsig / a homegrown &lt;code&gt;%&lt;/code&gt; cookie — same idea. The important part is &lt;strong&gt;default off for agent UI&lt;/strong&gt;, not which vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollout ladder (what we actually do)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Internal / dogfood&lt;/strong&gt; — flag on for the team only. Click the weird paths humans use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1–5%&lt;/strong&gt; — watch error rate, rage clicks, support tags for that surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;25% → 50%&lt;/strong&gt; — only if step 2 is quiet for a real workday, not twenty quiet minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100%&lt;/strong&gt; — then delete the legacy branch when you’re sure, not “someday.”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Agent UI fails differently than human UI: it often fails &lt;em&gt;narrowly&lt;/em&gt; (one plan tier, one locale, one empty-state branch). Small percentages catch that before it becomes a Monday war room.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I put in the agent prompt
&lt;/h2&gt;

&lt;p&gt;Feature flags are a release policy. The agent still needs to &lt;em&gt;build&lt;/em&gt; behind them:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Implement this UI behind flag &lt;code&gt;settings-billing-v2&lt;/code&gt; (default off).&lt;br&gt;&lt;br&gt;
Keep &lt;code&gt;SettingsBillingLegacy&lt;/code&gt; working and unchanged.&lt;br&gt;&lt;br&gt;
Do not delete legacy routes/components in this PR.&lt;br&gt;&lt;br&gt;
Wire the flag at the route/section boundary, not inside every leaf.&lt;br&gt;&lt;br&gt;
Add a short “flag off = legacy” note in the PR body.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Without that, agents “helpfully” delete the old panel and leave you with a binary deploy rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  PR template checkboxes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Agent UI rollout&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] New/changed user-visible UI is behind a named flag (default off)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Legacy path still renders when flag is off
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Flag name matches the ticket / surface (not the model name)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Rollout plan noted: dogfood → % → 100% (or “internal-only”)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Kill switch owner named (who can flip it in prod)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reviewers stop arguing “is this ready?” and start asking “who turns it on?”&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics I watch for the first day
&lt;/h2&gt;

&lt;p&gt;Not vanity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Error / exception rate on the flagged route&lt;/li&gt;
&lt;li&gt;Support tickets tagged with that surface&lt;/li&gt;
&lt;li&gt;Funnel drop at the new empty / CTA step&lt;/li&gt;
&lt;li&gt;Accessibility smoke (keyboard + one screen reader pass on dogfood)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those spike at 5%, you don’t “monitor overnight.” You flip the flag off and open a fix PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review comments I leave
&lt;/h2&gt;

&lt;p&gt;When the agent rewrote a panel with no flag:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This surface ships to every user on merge. Wrap it in &lt;code&gt;settings-billing-v2&lt;/code&gt; (default off) and keep legacy until we ramp. Agent UI doesn’t get a free pass on rollout.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When they deleted legacy in the same PR:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don’t delete &lt;code&gt;SettingsBillingLegacy&lt;/code&gt; here. Flag-off must still work. Removal is a follow-up after 100% + quiet metrics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the flag is hardcoded &lt;code&gt;true&lt;/code&gt; “for testing”:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hardcoded &lt;code&gt;true&lt;/code&gt; is a silent 100% rollout. Use the real flag client; leave default false in prod config.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Merging agent UI straight to 100% because staging looked pretty&lt;/li&gt;
&lt;li&gt;Naming flags after models (&lt;code&gt;gpt-settings-rewrite&lt;/code&gt;) instead of product surfaces&lt;/li&gt;
&lt;li&gt;Letting the agent delete legacy in the same PR that introduces the new UI&lt;/li&gt;
&lt;li&gt;Treating “Playwright green” as “safe for everyone”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feature-flagging AI-suggested UI isn’t distrust of the model — it’s admitting the model never saw your free-plan edge cases, your support queue, or your Monday traffic. Ship the code when it’s ready. Ship the &lt;em&gt;exposure&lt;/em&gt; when the numbers say so.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>featureflags</category>
      <category>react</category>
    </item>
    <item>
      <title>Constraining agent CSS to design tokens (without pasting the whole theme)</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:32:43 +0000</pubDate>
      <link>https://dev.to/sherotree/constraining-agent-css-to-design-tokens-without-pasting-the-whole-theme-19o8</link>
      <guid>https://dev.to/sherotree/constraining-agent-css-to-design-tokens-without-pasting-the-whole-theme-19o8</guid>
      <description>&lt;p&gt;I used to drop the entire &lt;code&gt;tokens.css&lt;/code&gt; into the agent prompt so it “wouldn’t invent colors.” It still invented colors. Just prettier ones — &lt;code&gt;#3B82F6&lt;/code&gt; instead of &lt;code&gt;blue&lt;/code&gt;, &lt;code&gt;14px&lt;/code&gt; instead of &lt;code&gt;space.3&lt;/code&gt;, a one-off radius that almost matched &lt;code&gt;radius.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Pasting the whole theme teaches the model what exists. It does &lt;strong&gt;not&lt;/strong&gt; teach it that everything else is illegal. Agents treat big token dumps like a style moodboard, then freestyle.&lt;/p&gt;

&lt;p&gt;Here’s the thinner constraint that actually stuck in our agent UI PRs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode: theme dump
&lt;/h2&gt;

&lt;p&gt;A prompt like this looks responsible and fails quietly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Here is our full design token file (800 lines of CSS variables). Style this settings panel to match.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What came back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hex that “felt close” to &lt;code&gt;--color-accent-primary&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;padding: 18px&lt;/code&gt; because “the mock looked about that”&lt;/li&gt;
&lt;li&gt;A new &lt;code&gt;--card-shadow-soft&lt;/code&gt; declared inline in the component&lt;/li&gt;
&lt;li&gt;Tailwind arbitrary values (&lt;code&gt;bg-[#0f172a]&lt;/code&gt;) next to token classes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model &lt;em&gt;saw&lt;/em&gt; the tokens. It optimized for “looks like our product,” not “uses only the legal surface.”&lt;/p&gt;

&lt;h2&gt;
  
  
  What “token-constrained CSS” means
&lt;/h2&gt;

&lt;p&gt;I define it as four hard rules the agent can fail against:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No raw values&lt;/strong&gt; — no hex, rgb, hsl, or bare &lt;code&gt;px&lt;/code&gt;/&lt;code&gt;rem&lt;/code&gt; for color, space, radius, or shadow in changed CSS/JSX&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import / reference allowlist&lt;/strong&gt; — only named tokens from the published set (&lt;code&gt;var(--space-4)&lt;/code&gt;, &lt;code&gt;theme('spacing.4')&lt;/code&gt;, &lt;code&gt;tokens.space[4]&lt;/code&gt;, whatever your stack uses)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No local token invention&lt;/strong&gt; — do not declare new &lt;code&gt;--*&lt;/code&gt; custom properties in feature CSS unless the ticket says “new token approved”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closest-token rule&lt;/strong&gt; — if the design asks for something off-scale, stop and name the gap; don’t invent a near-miss&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the prompt only says “use our design system,” the agent hears “make it pretty.” Name the bans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slim token pack I actually paste
&lt;/h2&gt;

&lt;p&gt;Instead of the whole theme, I keep a &lt;strong&gt;token cheat sheet&lt;/strong&gt; under ~40–60 lines and refresh it when the public scale changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Legal tokens (CSS / Tailwind / JS — same names)&lt;/span&gt;

&lt;span class="gu"&gt;### Color&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; bg.default, bg.subtle, bg.muted
&lt;span class="p"&gt;-&lt;/span&gt; fg.default, fg.muted, fg.danger
&lt;span class="p"&gt;-&lt;/span&gt; border.default, border.strong
&lt;span class="p"&gt;-&lt;/span&gt; accent.primary, accent.primary-hover
Forbidden: raw hex/rgb, inventing --color-&lt;span class="err"&gt;*&lt;/span&gt;, opacity hacks that bake a new tint

&lt;span class="gu"&gt;### Space&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; 1 2 3 4 5 6 8 10 12 (map to --space-&lt;span class="ge"&gt;* or spacing.*&lt;/span&gt;)
Forbidden: 13px, 18px, 22px, “eyeballed” padding, gap: 7

&lt;span class="gu"&gt;### Radius&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; sm | md | lg | full
Forbidden: border-radius: 10px, rounded-[10px]

&lt;span class="gu"&gt;### Shadow&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; shadow.sm | shadow.md | shadow.lg
Forbidden: one-off box-shadow stacks in feature CSS

&lt;span class="gu"&gt;### Type&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; font.size: sm | md | lg | xl
&lt;span class="p"&gt;-&lt;/span&gt; font.weight: regular | medium | semibold
Forbidden: font-size: 15px, font-weight: 450

&lt;span class="gu"&gt;## How to reference (pick your stack)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; CSS: var(--space-4), var(--color-fg-muted)
&lt;span class="p"&gt;-&lt;/span&gt; Tailwind: p-4, text-fg-muted, rounded-md (token-mapped utilities only)
&lt;span class="p"&gt;-&lt;/span&gt; JS/CSS-in-JS: tokens.space[4], tokens.color.fg.muted

&lt;span class="gu"&gt;## Acceptance (must pass before PR)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Zero hex/rgb/hsl in the diff
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Zero bare px for space/radius/type (layout hacks like 1px hairlines need a one-line NOTE)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Zero new --&lt;span class="err"&gt;*&lt;/span&gt; declarations outside tokens/
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Any off-scale design ask is listed as a blocker, not approximated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s the whole “theme” most feature tasks get. The full &lt;code&gt;tokens.css&lt;/code&gt; stays in the repo for humans and for codegen — the prompt carries the &lt;strong&gt;decision surface&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt shape that works
&lt;/h2&gt;

&lt;p&gt;My default ask looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Style X using &lt;strong&gt;only&lt;/strong&gt; the Legal tokens below.&lt;br&gt;&lt;br&gt;
Do not invent colors, spacing, radius, or shadows.&lt;br&gt;&lt;br&gt;
If a value isn’t on the scale, stop and name the missing token — don’t approximate.&lt;br&gt;&lt;br&gt;
Prefer existing utility / token classes over new CSS files.&lt;br&gt;&lt;br&gt;
When done, paste the Acceptance checklist with pass/fail and list every token you used.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I also add one explicit ban that cut a lot of noise:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Forbidden: Tailwind arbitrary values for color/space/radius (&lt;code&gt;bg-[#…]&lt;/code&gt;, &lt;code&gt;p-[18px]&lt;/code&gt;, &lt;code&gt;rounded-[10px]&lt;/code&gt;). If the utility doesn’t exist, the token doesn’t exist for this task.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Arbitrary values are how agents smuggle freestyle CSS past a “we use Tailwind” rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny CI gate that catches the same mistakes
&lt;/h2&gt;

&lt;p&gt;Prompts leak. I pair them with a cheap lint on agent-touched paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/check-no-raw-css-values.sh — run on changed UI files&lt;/span&gt;
rg &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--pcre2&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'(#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|\b\d+(\.\d+)?px\b|rounded-\[[^\]]+\]|bg-\[[^\]]+\]|p-\[[^\]]+\]|gap-\[[^\]]+\])'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  src/components src/app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'!**/tokens/**'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s1"&gt;'!**/*.test.*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;False positives happen (SVG path data, &lt;code&gt;1px&lt;/code&gt; hairlines). Allowlist those with a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* RAW_OK: 1px hairline separator — not a spacing token */&lt;/span&gt;
&lt;span class="nt"&gt;border-top&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt; &lt;span class="nt"&gt;solid&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--color-border-default&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point isn’t zero exceptions. It’s that every exception is &lt;strong&gt;named&lt;/strong&gt;, so agent PRs can’t quietly grow a second palette.&lt;/p&gt;

&lt;p&gt;For Tailwind shops, &lt;code&gt;eslint-plugin-tailwindcss&lt;/code&gt; with &lt;code&gt;no-arbitrary-value&lt;/code&gt; (or a custom restrict) on &lt;code&gt;src/features/**&lt;/code&gt; works even better — fail the PR when the model reaches for &lt;code&gt;[#0f172a]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I put in the PR template
&lt;/h2&gt;

&lt;p&gt;A short checkbox beats another paragraph in the system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Token compliance&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; [ ] No raw color / space / radius / shadow values in the diff
&lt;span class="p"&gt;-&lt;/span&gt; [ ] No new --&lt;span class="err"&gt;*&lt;/span&gt; tokens outside tokens/
&lt;span class="p"&gt;-&lt;/span&gt; [ ] Off-scale design requests called out (not approximated)
&lt;span class="p"&gt;-&lt;/span&gt; [ ] &lt;span class="sb"&gt;`scripts/check-no-raw-css-values.sh`&lt;/span&gt; (or ESLint) passed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reviewers stop arguing taste (“that blue is fine”) and start enforcing the scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review comments I actually leave
&lt;/h2&gt;

&lt;p&gt;When I see freestyle CSS in an agent diff:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Token miss: &lt;code&gt;padding: 18px&lt;/code&gt; isn’t on the space scale. Use &lt;code&gt;var(--space-4)&lt;/code&gt; / &lt;code&gt;p-4&lt;/code&gt; or stop and request a new token. Don’t approximate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the model invents a local variable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New &lt;code&gt;--card-shadow-soft&lt;/code&gt; belongs in &lt;code&gt;tokens/&lt;/code&gt; with a design OK, not in the feature file. Revert and use &lt;code&gt;shadow.md&lt;/code&gt; or file a token RFC.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When Tailwind arbitrary values show up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;bg-[#0f172a]&lt;/code&gt; is a banned escape hatch here. Map to &lt;code&gt;bg-subtle&lt;/code&gt; / &lt;code&gt;bg-muted&lt;/code&gt; or name the missing token. Arbitrary color values fail the token gate on purpose.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pasting the entire theme “for completeness”&lt;/li&gt;
&lt;li&gt;Saying “match the design system” without listing banned value types&lt;/li&gt;
&lt;li&gt;Trusting the model to “prefer tokens” without a lint gate&lt;/li&gt;
&lt;li&gt;Letting one-off hex land because “it’s only in this panel”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Constraining agent CSS to design tokens is less about teaching every variable name and more about &lt;strong&gt;shrinking the legal alphabet&lt;/strong&gt;. Give a short allowlist, ban raw values out loud, and fail CI when freestyle sneaks in. The full theme can stay in the repo — it just doesn’t need to live in every prompt.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>css</category>
      <category>designsystem</category>
    </item>
    <item>
      <title>Pair agent UI work with visual regression before you trust green CI</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:01:11 +0000</pubDate>
      <link>https://dev.to/sherotree/pair-agent-ui-work-with-visual-regression-before-you-trust-green-ci-5cji</link>
      <guid>https://dev.to/sherotree/pair-agent-ui-work-with-visual-regression-before-you-trust-green-ci-5cji</guid>
      <description>&lt;p&gt;Unit tests pass. Typecheck is green. Playwright happy-path clicks through. The agent PR still ships a button that jumped 8px, a modal that lost its shadow, and a table whose sticky header now overlaps the first row.&lt;/p&gt;

&lt;p&gt;I stopped treating “green CI” as proof that agent UI work is safe. Green means the &lt;em&gt;logic&lt;/em&gt; didn’t break. Layout and visual regressions need their own gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents are especially good at silent UI breaks
&lt;/h2&gt;

&lt;p&gt;Agents rewrite JSX and CSS with confidence. They:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swap &lt;code&gt;gap&lt;/code&gt; for &lt;code&gt;margin&lt;/code&gt; “to match the design”&lt;/li&gt;
&lt;li&gt;Drop a wrapper that “wasn’t doing anything” (it was)&lt;/li&gt;
&lt;li&gt;Change &lt;code&gt;flex&lt;/code&gt; → &lt;code&gt;grid&lt;/code&gt; mid-component without updating children&lt;/li&gt;
&lt;li&gt;Tweak token usage that only shows up at a specific viewport&lt;/li&gt;
&lt;li&gt;“Clean up” class names and lose a one-off override that fixed Safari&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that fails a unit test. Snapshot DOM tests often miss spacing. E2E that asserts text still passes. Humans catch it in review — if they open the preview and squint hard enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I mean by visual regression here
&lt;/h2&gt;

&lt;p&gt;Not a full design QA suite on day one. A &lt;strong&gt;thin, blocking&lt;/strong&gt; check on the surfaces the agent is allowed to touch:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Capture baseline screenshots of key states (default / hover / error / empty / mobile)&lt;/li&gt;
&lt;li&gt;On the PR, re-capture the same states&lt;/li&gt;
&lt;li&gt;Fail (or require human ACK) when pixels drift past a small threshold&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tools I’ve used in this role: Playwright screenshot diffs, Chromatic / Lost Pixel / Argos on Storybook stories, or a cheap custom Playwright compare in CI. The brand matters less than &lt;strong&gt;blocking merge&lt;/strong&gt; when the diff is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pair it with the agent prompt — don’t bolt it on after
&lt;/h2&gt;

&lt;p&gt;If visual checks only run after the model already rewrote half the page, you burn a review cycle. I put the expectation in the task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Visual regression (HARD for UI diffs)&lt;/span&gt;

You are editing UI. Before you finish:
&lt;span class="p"&gt;
1.&lt;/span&gt; List the stories / routes / states you changed (default, hover, focus, error, empty, sm/md breakpoints if layout changed).
&lt;span class="p"&gt;2.&lt;/span&gt; Do not claim “looks fine” without a screenshot or story URL.
&lt;span class="p"&gt;3.&lt;/span&gt; Prefer smallest CSS/DOM change that keeps existing visual baselines.
&lt;span class="p"&gt;4.&lt;/span&gt; If you must change layout structure, say so explicitly and list which baselines will need an intentional update.

Forbidden: drive-by spacing “cleanup”, deleting wrappers without checking computed layout, renaming classes that might be used in CSS modules / global CSS.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent still ships UI. It just knows silent restyles are not free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal Playwright gate that actually catches agent PRs
&lt;/h2&gt;

&lt;p&gt;I don’t need full Storybook coverage on every legacy page. For agent-heavy areas I pin a few routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// e2e/visual/agent-surfaces.spec.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;surfaces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;settings-billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/settings/billing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout-summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/checkout/summary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;empty-projects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/projects?empty=1&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;surfaces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`visual: &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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="k"&gt;await&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;goto&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="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&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;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveScreenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&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="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;maxDiffPixelRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;animations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disabled&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="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;maxDiffPixelRatio: 0.01&lt;/code&gt; is strict enough that an 8px shift fails, loose enough that font hinting noise rarely pages me. Tune per app.&lt;/p&gt;

&lt;p&gt;When an agent PR &lt;em&gt;intentionally&lt;/em&gt; changes the look, update the baseline in the same PR with a one-line note: &lt;code&gt;VISUAL_OK: billing card padding 16→20 per design&lt;/code&gt;. Silent baseline refreshes are how regressions hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storybook is even better when you already have stories
&lt;/h2&gt;

&lt;p&gt;If the component has stories, prefer visual diffs on stories over full-page shots:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Faster&lt;/li&gt;
&lt;li&gt;Stable (less app chrome / data noise)&lt;/li&gt;
&lt;li&gt;Matches how agents should work anyway — “edit &lt;code&gt;Button&lt;/code&gt; story states, not random pages”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My rule for agent UI tasks: &lt;strong&gt;no story, no merge&lt;/strong&gt; for shared components. Either the agent adds a story for the state it touched, or a human already had one. Page-level screenshots are the fallback for app shells.&lt;/p&gt;

&lt;h2&gt;
  
  
  What green CI means after this
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit / typecheck&lt;/td&gt;
&lt;td&gt;Logic + types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2E happy path&lt;/td&gt;
&lt;td&gt;Flows still click&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visual regression&lt;/td&gt;
&lt;td&gt;Spacing, color, overlay, breakpoint layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review&lt;/td&gt;
&lt;td&gt;Taste, a11y nuance, product intent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Agent PRs that are “all green” without a visual gate are only green on the first two rows. That’s incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review comments I actually leave
&lt;/h2&gt;

&lt;p&gt;When I see a UI-only agent diff with no visual evidence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Visual gate missing: this touches checkout summary layout. Add/update Playwright screenshot (or Storybook + Chromatic) for default + error + mobile, or paste preview screenshots in the PR. Don’t merge on typecheck alone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When baselines change without explanation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Baseline bump needs &lt;code&gt;VISUAL_OK: &amp;lt;why&amp;gt;&lt;/code&gt;. Pixel drift without a design reason is a regression, not a cleanup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I stopped trusting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;“I checked the preview” with no artifact&lt;/li&gt;
&lt;li&gt;DOM snapshots as a substitute for pixels&lt;/li&gt;
&lt;li&gt;Full E2E coverage as proof of layout&lt;/li&gt;
&lt;li&gt;Agent claims that spacing changes are “equivalent”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pair agent UI work with visual regression &lt;strong&gt;before&lt;/strong&gt; you trust green CI. Keep the suite small, block on drift, and make intentional visual changes explicit. The unit tests can stay green — your users still see the 8px jump.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>testing</category>
      <category>css</category>
    </item>
    <item>
      <title>When agents invent APIs — make grep-first a merge gate</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Mon, 02 Nov 2026 11:23:41 +0000</pubDate>
      <link>https://dev.to/sherotree/when-agents-invent-apis-make-grep-first-a-merge-gate-5h83</link>
      <guid>https://dev.to/sherotree/when-agents-invent-apis-make-grep-first-a-merge-gate-5h83</guid>
      <description>&lt;p&gt;I keep catching agent PRs that call helpers that do not exist. Not typos — &lt;em&gt;confident&lt;/em&gt; imports of &lt;code&gt;fetchUserProfileV2&lt;/code&gt;, &lt;code&gt;useCheckoutMachine&lt;/code&gt;, &lt;code&gt;formatMoneyLocale&lt;/code&gt;. Green TypeScript locally (because the agent also invented a stub), red in CI once the stub is gone, or worse: green forever with a hand-rolled fake that nobody asked for.&lt;/p&gt;

&lt;p&gt;The fix that stuck for me is boring: &lt;strong&gt;grep-first as a merge gate&lt;/strong&gt;. Before the model writes a call site, it has to prove the symbol already lives in the repo — or stop and ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode
&lt;/h2&gt;

&lt;p&gt;Agents optimize for “the feature works in this chat.” They do not feel the cost of a new private API. So they:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Import a name that &lt;em&gt;sounds&lt;/em&gt; like your codebase&lt;/li&gt;
&lt;li&gt;Add a 20-line util next to the feature instead of reusing the real one three folders away&lt;/li&gt;
&lt;li&gt;Duplicate a hook with a slightly different signature&lt;/li&gt;
&lt;li&gt;“Fix” missing exports by creating them, then leave the real module untouched&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reviewers then spend time deciding whether the new thing is intentional architecture or hallucinations with types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grep-first means what, exactly?
&lt;/h2&gt;

&lt;p&gt;In the prompt (and later in CI), I require:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Search before invent&lt;/strong&gt; — &lt;code&gt;rg&lt;/code&gt; / IDE search for the intended symbol, path, or behavior&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cite the hit&lt;/strong&gt; — paste the file path + a one-line snippet that proves it exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse or stop&lt;/strong&gt; — if nothing matches, do &lt;strong&gt;not&lt;/strong&gt; invent a public helper; either use a nearby existing pattern or stop with a short note: “no existing X; options are A/B”&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Inventing a &lt;em&gt;local&lt;/em&gt; private function inside the file you’re already editing is fine. Inventing a shared API is not — that’s a separate decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paste this into the agent prompt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## API invent ban (HARD)&lt;/span&gt;

Before you import or call any symbol outside the files you are allowed to edit:
&lt;span class="p"&gt;
1.&lt;/span&gt; Search the repo for an existing equivalent (&lt;span class="sb"&gt;`rg -n "functionName|HookName|ModuleName"`&lt;/span&gt;).
&lt;span class="p"&gt;2.&lt;/span&gt; In your reply, cite at least one real path + line that you will reuse.
&lt;span class="p"&gt;3.&lt;/span&gt; If search returns nothing: STOP. Do not create a new shared util/hook/service.
   List 2–3 options (reuse nearby pattern / extend existing module / new module needs human OK).
&lt;span class="p"&gt;4.&lt;/span&gt; Allowed without search: private helpers scoped to the current file only (not exported).

Do not invent &lt;span class="sb"&gt;`*V2`&lt;/span&gt;, &lt;span class="sb"&gt;`*New`&lt;/span&gt;, or parallel hooks “for clarity.”
Do not add barrel re-exports to paper over a missing symbol.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The STOP clause is the whole point. Without it, agents treat “prefer reuse” as vibes and keep typing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I grep for (frontend patterns)
&lt;/h2&gt;

&lt;p&gt;I don’t only search the exact name the model wants. I search for &lt;strong&gt;behavior&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Intent&lt;/th&gt;
&lt;th&gt;Grep seeds I actually try&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Money / currency format&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;formatMoney&lt;/code&gt;, &lt;code&gt;Intl.NumberFormat&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth header / session&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getSession&lt;/code&gt;, &lt;code&gt;authHeaders&lt;/code&gt;, &lt;code&gt;useUser&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature flag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;useFlag&lt;/code&gt;, &lt;code&gt;isEnabled&lt;/code&gt;, &lt;code&gt;Statsig&lt;/code&gt;, &lt;code&gt;LaunchDarkly&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics event&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;track(&lt;/code&gt;, &lt;code&gt;analytics.&lt;/code&gt;, &lt;code&gt;capture(&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date display&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;formatDate&lt;/code&gt;, &lt;code&gt;dayjs&lt;/code&gt;, &lt;code&gt;date-fns&lt;/code&gt;, &lt;code&gt;Temporal&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If any of those hit a shared module, the agent must wire that — not ship &lt;code&gt;utils/formatPrice.ts&lt;/code&gt; #4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Merge gate, not just a prompt
&lt;/h2&gt;

&lt;p&gt;Prompts alone drift. I also fail the PR when the diff introduces &lt;strong&gt;new exports&lt;/strong&gt; under shared folders without a checklist comment.&lt;/p&gt;

&lt;p&gt;Lightweight version that has worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# fail if agent added exports under shared API dirs without REVIEW_OK&lt;/span&gt;
git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; origin/dev...HEAD | rg &lt;span class="s1"&gt;'^src/(lib|shared|api|hooks)/'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in review (or a bot comment): any new file under those roots needs either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A link to the existing API it replaces, &lt;strong&gt;or&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A one-line &lt;code&gt;REVIEW_OK: new shared X because Y&lt;/code&gt; from a human&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m not trying to ban new APIs. I’m trying to ban &lt;strong&gt;silent&lt;/strong&gt; new APIs from a model that never looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I push back in review
&lt;/h2&gt;

&lt;p&gt;When I see a fresh helper that duplicates something:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Grep-first miss: &lt;code&gt;formatMoney&lt;/code&gt; already lives in &lt;code&gt;src/lib/money.ts&lt;/code&gt;. Drop &lt;code&gt;formatPriceLocale&lt;/code&gt;. Reuse the existing export. If the signature is wrong, extend that module in a follow-up — don’t fork it in the feature folder.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concrete path beats “please reuse existing utilities.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Exceptions I allow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generated clients&lt;/strong&gt; (OpenAPI / GraphQL) — inventing wrappers is still gated; calling generated types is fine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-file prototypes&lt;/strong&gt; behind a feature flag — still no new shared exports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;True greenfield modules&lt;/strong&gt; — human says so up front in the prompt (“create &lt;code&gt;src/lib/playback/&lt;/code&gt;”)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the task is “add a shared date lib,” say that. Don’t let “fix checkout copy” smuggle one in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiny checklist before I merge agent UI/API work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every new import outside the blast radius was grepped and cited&lt;/li&gt;
&lt;li&gt;[ ] No new files under &lt;code&gt;shared&lt;/code&gt; / &lt;code&gt;lib&lt;/code&gt; / &lt;code&gt;hooks&lt;/code&gt; without human OK&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;*V2&lt;/code&gt; / parallel hook next to an existing one&lt;/li&gt;
&lt;li&gt;[ ] Private helpers stay unexported&lt;/li&gt;
&lt;li&gt;[ ] CI or review bot can spot new shared exports&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Trusting “I checked the codebase” without a path citation&lt;/li&gt;
&lt;li&gt;Merging “small util, we’ll consolidate later”&lt;/li&gt;
&lt;li&gt;Letting TypeScript green mean the API is real&lt;/li&gt;
&lt;li&gt;Arguing about taste when the issue is a missing &lt;code&gt;rg&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agents invent APIs because inventing is cheap and searching is optional. Make search mandatory, cite the hit, and treat new shared exports as a merge gate. The feature still ships — just on the APIs you already own.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>typescript</category>
      <category>github</category>
    </item>
    <item>
      <title>Diff budgets for AI PRs: lines, files, and blast radius</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:33:31 +0000</pubDate>
      <link>https://dev.to/sherotree/diff-budgets-for-ai-prs-lines-files-and-blast-radius-gnj</link>
      <guid>https://dev.to/sherotree/diff-budgets-for-ai-prs-lines-files-and-blast-radius-gnj</guid>
      <description>&lt;p&gt;I stopped arguing with agents about “keep the PR small.” Small is mushy. What works is a &lt;strong&gt;diff budget&lt;/strong&gt; — hard numbers for lines, files, and blast radius — written into the prompt &lt;em&gt;before&lt;/em&gt; the model starts typing.&lt;/p&gt;

&lt;p&gt;Green CI on a 1,200-line agent PR is not a win. It’s a review hostage situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents blow past “small”
&lt;/h2&gt;

&lt;p&gt;Models don’t feel review pain. Given a feature ask, they’ll happily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Touch every file that &lt;em&gt;might&lt;/em&gt; be related&lt;/li&gt;
&lt;li&gt;Rewrite tests “for clarity” while fixing the bug&lt;/li&gt;
&lt;li&gt;Drive-by format / rename / import-sort half the package&lt;/li&gt;
&lt;li&gt;Open shared UI primitives because “the Button was almost right”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You said small. They optimized for complete. Completeness without a budget looks like a megadiff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three budgets I actually use
&lt;/h2&gt;

&lt;p&gt;I put all three in the prompt. Soft limits get ignored; hard stops get obeyed more often.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lines&lt;/strong&gt; — max changed lines (insertions + deletions), e.g. &lt;code&gt;≤ 200&lt;/code&gt; for a bugfix, &lt;code&gt;≤ 400&lt;/code&gt; for a feature slice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files&lt;/strong&gt; — max paths touched, e.g. &lt;code&gt;≤ 8&lt;/code&gt; (tests count)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius&lt;/strong&gt; — allowed directories / globs, e.g. only &lt;code&gt;src/features/checkout/**&lt;/code&gt; + colocated tests&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss any one and reviewers lose the thread. A 150-line change across 25 files is still painful. A 300-line change inside one feature folder is often fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paste this into the prompt
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Diff budget (HARD — stop if you would exceed)&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Max files: 8 (including tests / stories)
&lt;span class="p"&gt;-&lt;/span&gt; Max lines: 250 (git diff --stat total)
&lt;span class="p"&gt;-&lt;/span&gt; Blast radius: ONLY src/features/checkout/&lt;span class="ge"&gt;**&lt;/span&gt; and &lt;span class="ge"&gt;**&lt;/span&gt;/checkout&lt;span class="ge"&gt;*.test.*&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Do NOT edit: src/components/ui/&lt;span class="ge"&gt;**&lt;/span&gt;, shared hooks, CI, package.json, lockfiles

If the task needs more: STOP. List what you’d need and why.
Do not “almost finish” outside the fence.
Prefer a follow-up PR over expanding scope.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That STOP clause matters. Without it, agents treat the budget as a suggestion and keep going.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I pick the numbers
&lt;/h2&gt;

&lt;p&gt;Rough defaults I reuse on frontend work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind of change&lt;/th&gt;
&lt;th&gt;Files&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Typical fence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bug fix / copy / a11y&lt;/td&gt;
&lt;td&gt;1–4&lt;/td&gt;
&lt;td&gt;≤ 120&lt;/td&gt;
&lt;td&gt;One component + test/story&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small feature slice&lt;/td&gt;
&lt;td&gt;3–8&lt;/td&gt;
&lt;td&gt;≤ 250&lt;/td&gt;
&lt;td&gt;Feature folder only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refactor (rename/extract)&lt;/td&gt;
&lt;td&gt;2–6&lt;/td&gt;
&lt;td&gt;≤ 200&lt;/td&gt;
&lt;td&gt;One intent, no behavior change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration / codegen&lt;/td&gt;
&lt;td&gt;separate rules&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Often file-count heavy; budget by &lt;em&gt;intent&lt;/em&gt;, not vanity lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the real work can’t fit, I &lt;strong&gt;split the ask&lt;/strong&gt; before prompting — not after reviewing a monster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure, don’t vibe
&lt;/h2&gt;

&lt;p&gt;After the agent stops, I run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git diff &lt;span class="nt"&gt;--stat&lt;/span&gt;
git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;--stat&lt;/code&gt; or the file count is over budget, I don’t deep-review. I send it back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Over budget: 14 files / 480 lines. Fence was &lt;code&gt;checkout/**&lt;/code&gt;, max 8 / 250. Split into (1) UI state (2) API wiring — or justify the overrun in one paragraph.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Agents respond well to concrete overruns. “Too big” does nothing. Numbers do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blast radius beats line count
&lt;/h2&gt;

&lt;p&gt;Line budgets alone miss the scary cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One-line changes in &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;payments&lt;/code&gt;, and &lt;code&gt;i18n&lt;/code&gt; config&lt;/li&gt;
&lt;li&gt;Touching design-system primitives for a page-level tweak&lt;/li&gt;
&lt;li&gt;Editing both app code and generated GraphQL types “while here”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I always name &lt;strong&gt;allowed globs&lt;/strong&gt; and &lt;strong&gt;forbidden areas&lt;/strong&gt;. When the agent needs a shared Button change, I want a stop + a note — maybe that becomes PR #2 with its own reviewers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pair budgets with one intent
&lt;/h2&gt;

&lt;p&gt;A diff budget without one intent still fails. “Fix checkout + clean hooks + update Storybook” will thrash inside the numbers by doing three half-jobs.&lt;/p&gt;

&lt;p&gt;Prompt shape I like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One sentence intent&lt;/li&gt;
&lt;li&gt;Diff budget block&lt;/li&gt;
&lt;li&gt;Acceptance checks (tests / story / manual path)&lt;/li&gt;
&lt;li&gt;Explicit non-goals&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example non-goals: no renames, no dependency bumps, no “improve types” outside touched signatures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do when overrun is legitimate
&lt;/h2&gt;

&lt;p&gt;Sometimes 12 files is correct (rename across the feature). Then I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raise the file budget &lt;em&gt;and&lt;/em&gt; freeze line budget for behavior&lt;/li&gt;
&lt;li&gt;Or require &lt;strong&gt;one commit per concern&lt;/strong&gt; inside the PR&lt;/li&gt;
&lt;li&gt;Or stack two PRs: mechanical rename first, behavior second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I still write the new budget into the prompt. “Unlimited because rename” is how you get rename + rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiny checklist before I hit send
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Intent is one sentence&lt;/li&gt;
&lt;li&gt;[ ] File max + line max + path fence are numbers/globs, not adjectives&lt;/li&gt;
&lt;li&gt;[ ] Forbidden paths listed&lt;/li&gt;
&lt;li&gt;[ ] STOP-and-report rule present&lt;/li&gt;
&lt;li&gt;[ ] How I’ll measure (&lt;code&gt;git diff --stat&lt;/code&gt;) stated up front&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Saying “keep it reviewable” with no numbers&lt;/li&gt;
&lt;li&gt;Accepting “I had to touch Button” without a separate PR&lt;/li&gt;
&lt;li&gt;Reviewing over-budget diffs hoping the important hunks would jump out&lt;/li&gt;
&lt;li&gt;Letting test rewrites eat half the line budget on a one-line product fix&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Diff budgets turn “please be careful” into something the agent can fail against. Lines, files, blast radius — pick all three, put them in the prompt, and send the PR back when the meter trips. Review time stays scarce; the model’s typing speed doesn’t get a blank check.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>ai</category>
      <category>github</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Give the agent a failing Storybook story, not a vague “fix a11y”</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Sat, 31 Oct 2026 15:41:24 +0000</pubDate>
      <link>https://dev.to/sherotree/give-the-agent-a-failing-storybook-story-not-a-vague-fix-a11y-1iih</link>
      <guid>https://dev.to/sherotree/give-the-agent-a-failing-storybook-story-not-a-vague-fix-a11y-1iih</guid>
      <description>&lt;p&gt;I used to type “make this component accessible” into the agent and hope for the best. It would sprinkle &lt;code&gt;aria-label&lt;/code&gt;s, flip a couple of roles, and declare victory. CI still failed. Reviewers still found focus traps. The agent wasn’t wrong about &lt;em&gt;intent&lt;/em&gt; — I had given it a vibe instead of a failing unit.&lt;/p&gt;

&lt;p&gt;What actually works: hand the agent a &lt;strong&gt;red Storybook a11y story&lt;/strong&gt; (or the axe / Accessibility addon output for that story) and ask for the smallest fix that turns it green. Same pattern as pasting a Playwright failure — artifacts beat slogans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why “fix a11y” produces fake green
&lt;/h2&gt;

&lt;p&gt;Vague a11y prompts invite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decorative &lt;code&gt;aria-label&lt;/code&gt;s on elements that already have visible text&lt;/li&gt;
&lt;li&gt;Role swaps that silence axe but break keyboard order&lt;/li&gt;
&lt;li&gt;“Skip to content” links that aren’t in the story under test&lt;/li&gt;
&lt;li&gt;Color-contrast “fixes” that only work on the agent’s assumed theme&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agents optimize for the prompt you wrote. If the prompt is “be accessible,” they’ll invent compliance. If the prompt is “story &lt;code&gt;Button/Loading&lt;/code&gt; fails rule &lt;code&gt;button-name&lt;/code&gt; — fix only that,” they have a target.&lt;/p&gt;

&lt;h2&gt;
  
  
  The artifact I paste now
&lt;/h2&gt;

&lt;p&gt;I keep stories that are &lt;em&gt;supposed&lt;/em&gt; to fail until fixed — or I run the a11y addon / &lt;code&gt;@storybook/addon-a11y&lt;/code&gt; / &lt;code&gt;axe-playwright&lt;/code&gt; against a specific story id and paste the raw report.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Storybook a11y failure (paste raw)&lt;/span&gt;

Story id: ui-button--loading
Story file: src/components/Button/Button.stories.tsx
Component: src/components/Button/Button.tsx

&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="na"&gt;paste&lt;/span&gt; &lt;span class="na"&gt;axe&lt;/span&gt; &lt;span class="err"&gt;/&lt;/span&gt; &lt;span class="na"&gt;addon&lt;/span&gt; &lt;span class="na"&gt;violations:&lt;/span&gt; &lt;span class="na"&gt;rule&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;failureSummary&lt;/span&gt; &lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&amp;gt;&amp;gt;

Goal: smallest change so THIS story passes a11y for the listed rules
Out of scope: rewriting the design system, adding new variants, “improving” unrelated stories
Do not: invent aria-labels that duplicate visible text; do not remove interactive roles to silence axe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That “out of scope” line matters. Left alone, agents will refactor the Button API mid-fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the failure cheap to reproduce
&lt;/h2&gt;

&lt;p&gt;A few habits that keep this loop under five minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Name stories after states that break a11y&lt;/strong&gt; — &lt;code&gt;Loading&lt;/code&gt;, &lt;code&gt;IconOnly&lt;/code&gt;, &lt;code&gt;DisabledWithTooltip&lt;/code&gt;, &lt;code&gt;ErrorInline&lt;/code&gt;. Happy-path &lt;code&gt;Default&lt;/code&gt; rarely teaches the agent anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run one story in CI&lt;/strong&gt; (or locally) with axe attached — don’t wait for a full visual suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer rule ids in the prompt&lt;/strong&gt; (&lt;code&gt;button-name&lt;/code&gt;, &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;color-contrast&lt;/code&gt;, &lt;code&gt;focus-order&lt;/code&gt;) so the agent doesn’t “fix” a different problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Include the rendered HTML snippet&lt;/strong&gt; from the report when the DOM is the bug (extra wrappers, missing &lt;code&gt;for&lt;/code&gt;/&lt;code&gt;id&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example constraint I reuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;You are fixing ONE Storybook a11y failure.

Rules:
&lt;span class="p"&gt;-&lt;/span&gt; Change the component or the story controls — prefer the component if the bug is real
&lt;span class="p"&gt;-&lt;/span&gt; Keep visible labels; do not hide text and replace it with aria-only labels
&lt;span class="p"&gt;-&lt;/span&gt; Icon-only buttons need an accessible name that matches the UI meaning
&lt;span class="p"&gt;-&lt;/span&gt; If contrast fails, use design tokens / existing theme vars — do not hardcode hex the design system doesn’t own
&lt;span class="p"&gt;-&lt;/span&gt; If the failure looks like a false positive from the test harness, STOP and say so — do not paper over it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What good agent output looks like
&lt;/h2&gt;

&lt;p&gt;I want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which rule failed and why&lt;/strong&gt; in one sentence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The minimal diff&lt;/strong&gt; (often 5–20 lines)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How to re-check&lt;/strong&gt; (&lt;code&gt;storybook --test&lt;/code&gt; / addon panel / axe on that story id)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What they deliberately did &lt;em&gt;not&lt;/em&gt; change&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reject “added aria-label everywhere.” That’s the vibe fix again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pair this with review, not instead of it
&lt;/h2&gt;

&lt;p&gt;A green Storybook story is necessary, not sufficient. Humans still catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Focus order that axe doesn’t flag as broken but feels wrong in a modal&lt;/li&gt;
&lt;li&gt;Live regions that announce too often&lt;/li&gt;
&lt;li&gt;Mobile zoom / dynamic type issues outside the story viewport&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the workflow is: agent clears the &lt;strong&gt;measurable&lt;/strong&gt; violation → you tab through the story once → then merge. Don’t skip the tab-through because axe went quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tiny checklist before you prompt
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Story id + file path&lt;/li&gt;
&lt;li&gt;[ ] Raw violation list (rule + node)&lt;/li&gt;
&lt;li&gt;[ ] Theme / mode if contrast is involved (light/dark)&lt;/li&gt;
&lt;li&gt;[ ] Explicit “do not invent labels / do not delete assertions”&lt;/li&gt;
&lt;li&gt;[ ] Re-run command for that story only&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pasting Figma screenshots and saying “make it WCAG”&lt;/li&gt;
&lt;li&gt;Asking for a full a11y audit of a folder in one shot&lt;/li&gt;
&lt;li&gt;Accepting blanket &lt;code&gt;aria-hidden&lt;/code&gt; on icons without checking the button name&lt;/li&gt;
&lt;li&gt;Treating addon green as “we’re done” without a keyboard pass&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Give the agent a failing story. Get a patch you can re-run. Save the sermons for design critique — the model is much better at turning red axe output green than at inventing your accessibility policy from a slogan.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>a11y</category>
      <category>storybook</category>
      <category>ai</category>
    </item>
    <item>
      <title>GIF to Live Photo without leaving the tab</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:28:46 +0000</pubDate>
      <link>https://dev.to/sherotree/gif-live-photo-without-leaving-the-tab-164h</link>
      <guid>https://dev.to/sherotree/gif-live-photo-without-leaving-the-tab-164h</guid>
      <description>&lt;p&gt;Someone on the marketing channel drops a looping GIF and asks if we can use it as an iPhone Lock Screen. Half the team is on Linux. The other half has never heard of a &lt;code&gt;.livp&lt;/code&gt; file. Nobody wants to install another "GIF to Live Photo" Mac app for a one-off wallpaper request.&lt;/p&gt;

&lt;p&gt;For a while my path was: find a cloud converter, upload the GIF, download something that &lt;em&gt;might&lt;/em&gt; open on iPhone, AirDrop it, discover Photos rejected the package, try again. That was fine for memes. It was wrong the moment the GIF included unreleased UI or a client brand mark.&lt;/p&gt;

&lt;p&gt;These days I convert GIF → Live Photo in the browser, preview the loop in the same tab, download a &lt;code&gt;.livp&lt;/code&gt;, and only then AirDrop to the phone. No desktop app. No account. No "we delete uploads in 24h" promise I can't verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shows up in frontend work
&lt;/h2&gt;

&lt;p&gt;Live Photos are an Apple Photos format: a still JPEG paired with a short H.264 movie, tied together with a content identifier and a still-image-time track. Designers and PMs want them for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lock Screen / wallpaper motion from an existing GIF&lt;/li&gt;
&lt;li&gt;iMessage shares that feel more "Photos" than "Giphy"&lt;/li&gt;
&lt;li&gt;Reaction loops that should press-and-hold like a real Live Photo&lt;/li&gt;
&lt;li&gt;Demo assets for iOS marketing that started life as a Slack GIF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's almost never a product feature. It's a &lt;strong&gt;format bridge&lt;/strong&gt; between "we already have a GIF" and "Photos will accept it." Wiring FFmpeg into the app for that is usually overkill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload tax (again)
&lt;/h2&gt;

&lt;p&gt;Most "GIF to Live Photo online" tools still POST the bytes. The UI looks local; the Network panel says otherwise. For anything from a private beta, client work, or staging UI, that's the wrong default.&lt;/p&gt;

&lt;p&gt;What I want for this job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Frames stay in the tab&lt;/strong&gt; — decode → encode locally (WASM / FFmpeg-in-browser), then download&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview before download&lt;/strong&gt; — so I catch a broken loop before AirDrop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No signup&lt;/strong&gt; before the download button works&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real &lt;code&gt;.livp&lt;/code&gt;&lt;/strong&gt; Photos can import — not a lone MOV with "hope for the best" metadata&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survives Offline&lt;/strong&gt; in DevTools after the first WASM load&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If convert dies the moment I flip Offline (after the runtime is cached), I close the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick check before I trust the tool
&lt;/h2&gt;

&lt;p&gt;Same habit as with HEIC converters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After dropping a redacted sample GIF and clicking Convert:&lt;/span&gt;
&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resource&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="nf"&gt;filter&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initiatorType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initiatorType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xmlhttprequest&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="nf"&gt;map&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;r&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect a one-time fetch for the WASM/FFmpeg core. Do &lt;strong&gt;not&lt;/strong&gt; expect a POST of the GIF itself to some &lt;code&gt;api.*&lt;/code&gt; host. Then flip Offline and convert again — if export still works, the bytes stayed local.&lt;/p&gt;

&lt;p&gt;Always use a &lt;strong&gt;redacted sample&lt;/strong&gt; first (no faces, no product chrome). Never validate the trust model with the real asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop I reuse
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Redacted sample GIF first
2. Open a browser-local GIF → Live Photo tool
3. Drop → convert → preview the loop in-tab
4. Download .livp
5. AirDrop to iPhone → open from Files → save to Photos
6. Confirm press-and-hold motion + still look right
7. Close the tab, then run the real GIF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood the good tools encode frames to H.264, pair a JPEG still, stamp Apple's content identifier / still-image-time metadata, and zip that into &lt;code&gt;.livp&lt;/code&gt;. You &lt;em&gt;can&lt;/em&gt; roll your own with &lt;code&gt;ffmpeg.wasm&lt;/code&gt; if this is a weekly pipeline. For "someone Slack'd a wallpaper GIF," a solid tab beats a side project.&lt;/p&gt;

&lt;p&gt;When I need that tab fast, I've used &lt;a href="https://www.uwarp.design/gif-to-livephoto" rel="noopener noreferrer"&gt;Uwarp's GIF to Live Photo tool&lt;/a&gt; — drop the GIF, convert in the browser, preview, download &lt;code&gt;.livp&lt;/code&gt;. Worth trying when you don't want an App Store install or another upload site in the critical path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips that actually matter for wallpaper GIFs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep it short.&lt;/strong&gt; Live Photos only play a few seconds; trim long GIFs first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer clean loops.&lt;/strong&gt; A hard cut mid-motion looks worse after H.264 than a GIF that already looped cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portrait or square for Lock Screen.&lt;/strong&gt; Wide landscape memes waste the phone chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finish on device.&lt;/strong&gt; AirDrop the &lt;code&gt;.livp&lt;/code&gt; and open it on iPhone so Photos can register the paired still + movie. Don't expect Linux to "preview" a Live Photo the way Photos does.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When you &lt;em&gt;should&lt;/em&gt; put this in the product
&lt;/h2&gt;

&lt;p&gt;Sometimes the one-off habit is wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users upload GIFs and your iOS app must emit Live Photos at the API boundary&lt;/li&gt;
&lt;li&gt;You're building a creator tool where Live Photo export is a checked feature&lt;/li&gt;
&lt;li&gt;You need batch conversion with consistent CRF / duration caps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then own the encode path (server or WASM), test the metadata, and don't paper over failures with "please use this random website." That's product work.&lt;/p&gt;

&lt;p&gt;For internal wallpaper requests and marketing one-offs? Keep it out of the codebase. Convert once in a tab, AirDrop, move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  DX note: agents and media uploads
&lt;/h2&gt;

&lt;p&gt;If an agent is helping with asset pipelines, constrain it: &lt;em&gt;browser-local convert or local FFmpeg; no third-party upload of product or client assets.&lt;/em&gt; Agents default to whichever cloud converter snippet ranks first — which is often the upload path you're trying to avoid. Paste the policy into the prompt the same way you'd paste a lint rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Installing a one-purpose Mac app for a single Lock Screen request&lt;/li&gt;
&lt;li&gt;Uploading unreleased UI GIFs to the first Google result for "gif to live photo"&lt;/li&gt;
&lt;li&gt;Shipping a lone MOV and hoping Photos treats it as a Live Photo&lt;/li&gt;
&lt;li&gt;Asking the agent to "just host the GIF somewhere and convert it"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GIF → Live Photo is a packaging problem, not a collaboration problem. Fix it in the browser, verify on the phone, close the tab. If you want a concrete starting point, the &lt;a href="https://www.uwarp.design/gif-to-livephoto" rel="noopener noreferrer"&gt;GIF → Live Photo page on Uwarp&lt;/a&gt; is the one I've reached for when the team is mostly on Linux and nobody wants another desktop converter.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>dx</category>
    </item>
    <item>
      <title>HEIC from iPhone in the browser — no desktop converter, no upload</title>
      <dc:creator>Sherotree</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:00:48 +0000</pubDate>
      <link>https://dev.to/sherotree/heic-from-iphone-in-the-browser-no-desktop-converter-no-upload-egd</link>
      <guid>https://dev.to/sherotree/heic-from-iphone-in-the-browser-no-desktop-converter-no-upload-egd</guid>
      <description>&lt;p&gt;I still get HEIC files dropped into Slack with a cheerful "can you resize this for the docs?" They look fine on the sender's Mac. They open as a mystery blob on my Linux box, and half the preview tools just shrug.&lt;/p&gt;

&lt;p&gt;For a long time my default was: AirDrop to a Mac, open Preview, Export as JPEG, send it back. Or worse — upload to whichever "free HEIC converter" ranked first that week, wait for a download, hope the file wasn't sitting on someone else's disk. That worked until I started treating staging screenshots and unreleased UI as sensitive by default.&lt;/p&gt;

&lt;p&gt;These days I convert HEIC → JPG in the browser and leave. No desktop app, no account, no "we delete in 24h" promise I can't audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HEIC shows up in frontend work
&lt;/h2&gt;

&lt;p&gt;iPhones default to HEIC. Designers and PMs on iOS share photos that way without thinking. Frontend folks then need a JPG (or PNG) for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs / Notion / GitHub markdown that won't render HEIC&lt;/li&gt;
&lt;li&gt;Lighthouse / LCP samples where you want a boring baseline format&lt;/li&gt;
&lt;li&gt;PR comments that reviewers can open on Windows or Linux&lt;/li&gt;
&lt;li&gt;Tiny marketing crops that shouldn't require Photopea + an account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a one-off file job, not a product feature. That matters: the right fix is usually &lt;strong&gt;transform once and move on&lt;/strong&gt;, not wire &lt;code&gt;heic2any&lt;/code&gt; into the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload tax is the real bug
&lt;/h2&gt;

&lt;p&gt;Most "HEIC to JPG online" tools still POST the bytes. Even when the UI feels local, the Network panel tells on them. For client UI, brand marks, or anything from a private beta, that's the wrong default.&lt;/p&gt;

&lt;p&gt;What I want for this job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;File stays in the tab&lt;/strong&gt; — &lt;code&gt;FileReader&lt;/code&gt; / WASM / canvas, then &lt;code&gt;URL.createObjectURL&lt;/code&gt; for download&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No signup&lt;/strong&gt; before the download button works&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Survives Offline&lt;/strong&gt; in DevTools (cheap litmus test)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boring output&lt;/strong&gt; — a real &lt;code&gt;.jpg&lt;/code&gt; I can open in another tool, not a "close enough" re-encode with a surprise watermark&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 3 fails, I close the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick Offline check I actually run
&lt;/h2&gt;

&lt;p&gt;Before I trust a converter with a real file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After dropping a redacted sample HEIC and clicking Convert:&lt;/span&gt;
&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resource&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="nf"&gt;filter&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initiatorType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;initiatorType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xmlhttprequest&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="nf"&gt;map&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;r&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If convert fires a POST to some &lt;code&gt;api.*&lt;/code&gt; host, it wasn't local. Flip Offline and try again — if export dies, same conclusion.&lt;/p&gt;

&lt;p&gt;I keep a &lt;strong&gt;redacted sample&lt;/strong&gt; HEIC (no faces, no product UI) for that first pass. Never test the trust model with the real asset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser-local HEIC → JPG without a desktop app
&lt;/h2&gt;

&lt;p&gt;The loop I reuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Redacted sample first
2. Open a browser-local HEIC→JPG tool
3. Drop file → convert → download JPG
4. Verify in a second app (Preview / feh / Photos)
5. Close the tab (don't leave the bytes in IndexedDB forever)
6. Only then run the real file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood the good tools are doing some flavor of decode → canvas/draw → &lt;code&gt;toBlob('image/jpeg')&lt;/code&gt;. You can roll your own with a WASM decoder if this is a daily pipeline. For "someone Slack'd me three HEICs," a solid tab beats a side project.&lt;/p&gt;

&lt;p&gt;When I need that tab fast, I've used &lt;a href="https://www.uwarp.design/heic-to-jpg" rel="noopener noreferrer"&gt;Uwarp's HEIC to JPG tool&lt;/a&gt; — drop the file, convert in the browser, download. Worth trying when you don't want Preview, ImageMagick, or another upload site in the critical path.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you &lt;em&gt;should&lt;/em&gt; put HEIC support in the product
&lt;/h2&gt;

&lt;p&gt;Sometimes the one-off habit is the wrong answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users upload camera rolls and you must accept HEIC at the API boundary&lt;/li&gt;
&lt;li&gt;You're building an editor that should round-trip EXIF / orientation&lt;/li&gt;
&lt;li&gt;Marketing wants "works with iPhone photos" as a checked feature&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then own the decode path in the app (server-side or WASM), add tests for orientation, and don't paper over it with "please export as JPG first" copy. That's product work.&lt;/p&gt;

&lt;p&gt;For internal docs and PR screenshots? Keep it out of the codebase. Convert once, commit the JPG, move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  DX note: agents and HEIC
&lt;/h2&gt;

&lt;p&gt;If an agent is helping with asset pipelines, don't ask it to "fix the HEIC" by inventing a random CDN upload. Give it a constrained prompt: &lt;em&gt;browser-local convert or ImageMagick locally; no third-party upload of product assets.&lt;/em&gt; Agents default to whatever Stack Overflow snippet looks fastest — which is often the upload path you're trying to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I stopped doing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AirDrop → Preview → Export as the only path on a Linux day&lt;/li&gt;
&lt;li&gt;Pasting unreleased UI into the first Google result for "heic to jpg"&lt;/li&gt;
&lt;li&gt;Trusting a green progress bar without checking the Network panel&lt;/li&gt;
&lt;li&gt;Installing a one-purpose desktop converter for a job that finishes in a tab&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;HEIC from iPhone is a format mismatch, not a collaboration problem. Fix it in the browser, verify the JPG, close the tab. If you want a concrete starting point, the &lt;a href="https://www.uwarp.design/heic-to-jpg" rel="noopener noreferrer"&gt;HEIC → JPG page on Uwarp&lt;/a&gt; is the one I've reached for when Preview isn't around.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>dx</category>
    </item>
  </channel>
</rss>
