<?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: Isaiah Kim</title>
    <description>The latest articles on DEV Community by Isaiah Kim (@kyisaiah47).</description>
    <link>https://dev.to/kyisaiah47</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%2F3981339%2F7515e39c-98fe-4819-bcc5-d59ace01fc25.jpeg</url>
      <title>DEV Community: Isaiah Kim</title>
      <link>https://dev.to/kyisaiah47</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kyisaiah47"/>
    <language>en</language>
    <item>
      <title>Ranking a dashboard by funnel depth, not counts</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Mon, 27 Jul 2026 14:21:10 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/ranking-a-dashboard-by-funnel-depth-not-counts-1npe</link>
      <guid>https://dev.to/kyisaiah47/ranking-a-dashboard-by-funnel-depth-not-counts-1npe</guid>
      <description>&lt;p&gt;I had a metrics page that told me nothing. Page views per host, signups, a couple of Stripe lines, all rendered as cards in a grid. Every morning I'd look at it, feel vaguely informed, and then go build whatever I'd already decided to build the night before. That's the tell: if the dashboard never changes what you do next, it isn't a dashboard, it's decoration.&lt;/p&gt;

&lt;p&gt;So I rewrote it around one question — how far do people actually get — and let that ordering decide what I see first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ordering is the product
&lt;/h2&gt;

&lt;p&gt;Kynth Studios ships a lot of separate products on separate hosts: FetchDue chasing overdue invoices for agencies, BenchFile doing NYC Local Law 84 filings, CertScope determining CPSC scope per SKU, ParseRail for developers integrating against the API. They don't share a funnel shape in any interesting way, but they do share a funnel &lt;em&gt;depth&lt;/em&gt;: someone lands, someone starts the demo, someone finishes the guided run, someone starts signup, someone finishes it, someone activates.&lt;/p&gt;

&lt;p&gt;Depth is the only axis where those products are comparable. A traffic number tells me a host got attention. A depth number tells me where attention stopped.&lt;/p&gt;

&lt;p&gt;So the model is a stage list plus a rollup per product:&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;// stages are ordered; index IS the depth&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;demo_start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;demo_complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_complete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activated&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;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ProductFunnel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Stage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// deepest stage a product has ANY reach into&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProductFunnel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// the first stage that eats everything&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProductFunnel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Stage&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="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;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;STAGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;depth&lt;/code&gt; sorts the list. &lt;code&gt;stall&lt;/code&gt; is the label on each row. Everything else on the page is subordinate to those two functions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqcc1wctm9rmebpzwulea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqcc1wctm9rmebpzwulea.png" alt="Ranking a dashboard by funnel depth, not counts — code" width="800" height="1003"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Vanity metrics are the ones with no stage below them
&lt;/h2&gt;

&lt;p&gt;The rule I ended up with for what stays on the page: a metric earns its spot if there's a next stage it's supposed to feed. Page views feed demo starts. Demo starts feed completions. Completions feed signup starts.&lt;/p&gt;

&lt;p&gt;Page views on their own feed nothing I control, so they came off. Same with a top-level "total events" number, which mostly moved when I deployed something. Anything that only ever goes up and never implies an action is a metric you can watch forever without learning anything.&lt;/p&gt;

&lt;p&gt;The one that hurt to cut was a combined cross-product total. It looked healthy. It was healthy — it just averaged a product where people finish the demo together with one where nobody gets past the landing page, and the average was never the thing I needed to know. Now every row is per-product and the sort does the summarizing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the data engine away from the rendering
&lt;/h2&gt;

&lt;p&gt;The part I'd do again on any dashboard: the thing that computes stages, predicates, sorts and rollups is framework-agnostic and knows nothing about React. It takes events, returns &lt;code&gt;ProductFunnel[]&lt;/code&gt; sorted by depth. The components take that array and draw it.&lt;/p&gt;

&lt;p&gt;That split has paid off more than once. When a number looks wrong, the first question is which half is wrong — and because the contract between them is a plain typed object, I can answer it by calling the rollup in a test and reading the output. A rendering bug costs nothing on the engine side. A rollup bug is visible without a browser.&lt;/p&gt;

&lt;p&gt;It also means the same rollup feeds a morning summary that isn't a web page at all. Same functions, different renderer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the page actually looks like now
&lt;/h2&gt;

&lt;p&gt;One list. Products sorted deepest-first, each row showing its stall stage and the counts on either side of it. FetchDue near the top because there's a one-click live demo with no signup form in front of it, so people reach the guided run. The compliance products — BenchFile, GoodStanding, DoseTrace — sit lower, because a done-for-you filing service has a different entry shape and the interesting stage is further along.&lt;/p&gt;

&lt;p&gt;That ordering is doing real work. Last week it put a product at the bottom of the list whose landing page I thought was fine, with a stall at &lt;code&gt;demo_start&lt;/code&gt; — plenty of people arriving, nobody starting. That's a specific bug in a specific place, which is more than the old grid ever gave me.&lt;/p&gt;

&lt;p&gt;FetchDue has no customers yet and the dashboard says so; the honest zero is on the page next to a link to the live product and a recorded run. I'd rather look at a zero I can locate than a total I can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit I'd tell someone rebuilding theirs
&lt;/h2&gt;

&lt;p&gt;Pick the axis before you pick the charts. I spent longer than I should have arranging cards, and none of that time mattered, because the layout question and the ranking question are different questions and only one of them changes what you build tomorrow.&lt;/p&gt;

&lt;p&gt;Sorting by depth also has a property I didn't expect: the top of the list is the least urgent thing. The products getting people deepest need the least attention. Reading the page bottom-up is the actual workflow, which means the sort could arguably be reversed — I've left it deepest-first because I want the ceiling visible, and I go to the bottom on purpose.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>product</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Seek-safe animation: one paused timeline, scrubbed</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Sat, 25 Jul 2026 14:21:58 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/seek-safe-animation-one-paused-timeline-scrubbed-4jmp</link>
      <guid>https://dev.to/kyisaiah47/seek-safe-animation-one-paused-timeline-scrubbed-4jmp</guid>
      <description>&lt;p&gt;I've been building HyperFrames, the animation engine behind the product demo videos I make at Kynth. It renders video from HTML: a composition is an HTML file whose DOM declares its own timing with &lt;code&gt;data-*&lt;/code&gt; attributes, and the renderer turns that into frames. The same pipeline cuts the walkthroughs for FetchDue, CertScope and DoseTrace, alongside the Playwright harness that drives a real browser through each product and the edge-tts narration track.&lt;/p&gt;

&lt;p&gt;The whole design collapses into one requirement: &lt;strong&gt;same input time → same pixels&lt;/strong&gt;. Everything below is what that requirement cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a render pipeline need a paused timeline instead of playback?
&lt;/h2&gt;

&lt;p&gt;Because there is no playback. The renderer doesn't watch an animation run and grab screenshots — it takes a time value, seeks the page to it, and reads a pixel buffer. Frames can be sampled out of order, and they can be sampled in parallel across workers. Nothing is allowed to depend on having arrived at this frame &lt;em&gt;through&lt;/em&gt; the previous one.&lt;/p&gt;

&lt;p&gt;That kills a whole category of normal web animation. &lt;code&gt;requestAnimationFrame&lt;/code&gt; loops accumulate state. &lt;code&gt;setTimeout&lt;/code&gt; chains assume wall-clock progression. Scroll and hover listeners never fire, because the renderer has no input events. If a visual only looks right after the browser has been sitting there for two seconds, it will never look right in a render.&lt;/p&gt;

&lt;p&gt;So every composition registers exactly one paused GSAP timeline, built synchronously at page load, keyed by the composition's own id:&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;// index.html — built at load, never played&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gsap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#headline&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;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;power2.out&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;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#rows .row&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;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stagger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-=0.2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__timelines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice-scene&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="nx"&gt;tl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key has to match &lt;code&gt;data-composition-id&lt;/code&gt; on the root element exactly. No &lt;code&gt;-mount&lt;/code&gt; or &lt;code&gt;-host&lt;/code&gt; suffix, which I know because I spent real time staring at a blank frame that previewed perfectly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule that makes the rest fall out
&lt;/h3&gt;

&lt;p&gt;Build the timeline &lt;strong&gt;synchronously&lt;/strong&gt;. Not inside &lt;code&gt;async&lt;/code&gt;, not in a &lt;code&gt;Promise.then&lt;/code&gt;, not in a &lt;code&gt;setTimeout&lt;/code&gt;. The renderer can seek before an async builder finishes, and when it does you get a frame of the static HTML with none of the motion applied — and no error, because nothing failed. It's just early.&lt;/p&gt;

&lt;p&gt;Once that rule holds, seeking is a one-liner (&lt;code&gt;tl.seek(t)&lt;/code&gt;), and the interesting work moves to everything that &lt;em&gt;isn't&lt;/em&gt; the timeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyhg6pimnqmz8fj4xsst9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyhg6pimnqmz8fj4xsst9.png" alt="Seek-safe animation: one paused timeline, scrubbed — code" width="799" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually breaks determinism in practice?
&lt;/h2&gt;

&lt;p&gt;Clocks and randomness are the obvious ones, and they're the easy ones to lint. &lt;code&gt;Date.now()&lt;/code&gt;, &lt;code&gt;performance.now()&lt;/code&gt;, unseeded &lt;code&gt;Math.random()&lt;/code&gt; — banned outright for anything that affects visual state. If you want random-looking placement, seed a PRNG so the scatter is identical on every run.&lt;/p&gt;

&lt;p&gt;The subtler ones:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infinite loops.&lt;/strong&gt; &lt;code&gt;repeat: -1&lt;/code&gt; has no defined state at time &lt;em&gt;t&lt;/em&gt; in a parallel sampler. Compute a finite count from the clip's duration instead:&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;repeats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;cycleDuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&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;floor&lt;/code&gt;, not &lt;code&gt;ceil&lt;/code&gt;. &lt;code&gt;ceil&lt;/code&gt; overshoots the declared duration, and &lt;code&gt;Math.max(0, …)&lt;/code&gt; stops a short clip from producing &lt;code&gt;-1&lt;/code&gt; — which is infinite again, by the back door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two timelines touching the same property.&lt;/strong&gt; GSAP's overwrite behavior is order-dependent. If two tweens both animate &lt;code&gt;opacity&lt;/code&gt; on the same element in the same window, which one wins depends on evaluation order, and that can flip between renders. This one is nasty because it usually looks fine and then produces one wrong frame in the middle of a delivered video.&lt;/p&gt;

&lt;h3&gt;
  
  
  Never measure the DOM at tween time
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;getBoundingClientRect()&lt;/code&gt; inside a tween callback is the desync I hit most while building scene templates. It reads fine in preview, where frames arrive in order. Under a parallel renderer, the element you're measuring may be in a different state than you assumed, so the number you get back depends on sampling order.&lt;/p&gt;

&lt;p&gt;The fix is boring: compute layout constants once at composition setup and reuse them. Same for text — for anything dynamic, measure through the framework's own text helpers (&lt;code&gt;fitTextFontSize&lt;/code&gt;, or the &lt;code&gt;pretext&lt;/code&gt; measurement layer under it) rather than reflowing the DOM per frame.&lt;/p&gt;

&lt;p&gt;There's an allowlist for what can be animated at all: &lt;code&gt;opacity&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt;, &lt;code&gt;y&lt;/code&gt;, &lt;code&gt;scale&lt;/code&gt;, &lt;code&gt;rotation&lt;/code&gt;, &lt;code&gt;color&lt;/code&gt;, &lt;code&gt;backgroundColor&lt;/code&gt;, &lt;code&gt;borderRadius&lt;/code&gt;, transforms. Never &lt;code&gt;display&lt;/code&gt;, never raw &lt;code&gt;visibility&lt;/code&gt;, and never &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt;/&lt;code&gt;top&lt;/code&gt;/&lt;code&gt;left&lt;/code&gt; for layout changes. Transforms compose predictably at an arbitrary time; layout properties trigger reflow and take their neighbors with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you seek a runtime that isn't GSAP?
&lt;/h2&gt;

&lt;p&gt;You make each runtime publish something the engine can address by time, and you give the engine one adapter per runtime. GSAP covers most motion work, but a composition can mix runtimes, and each one seeks differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lottie&lt;/strong&gt; — every player registers itself on &lt;code&gt;window.__hfLottie&lt;/code&gt;, and the adapter calls &lt;code&gt;goToAndStop(timeMs, false)&lt;/code&gt; on &lt;code&gt;lottie-web&lt;/code&gt; instances, or the frame/percentage API on dotLottie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Animations API&lt;/strong&gt; — the adapter walks &lt;code&gt;document.getAnimations()&lt;/code&gt;, sets each &lt;code&gt;currentTime&lt;/code&gt; to HyperFrames time in milliseconds, then pauses it. Author with &lt;code&gt;fill: "both"&lt;/code&gt; so seeked states persist past their active window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS keyframes&lt;/strong&gt; — the adapter finds elements with a computed &lt;code&gt;animation-name&lt;/code&gt;, seeks their &lt;code&gt;Animation&lt;/code&gt; handles when the browser exposes them, and otherwise falls back to pausing with a negative &lt;code&gt;animation-delay&lt;/code&gt;. Use &lt;code&gt;animation-fill-mode: both&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three.js&lt;/strong&gt; — the adapter deliberately does &lt;em&gt;not&lt;/em&gt; own your scene. It sets &lt;code&gt;window.__hfThreeTime&lt;/code&gt; and dispatches &lt;code&gt;hf-seek&lt;/code&gt;, and your composition renders that exact time:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hf-seek&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;mixer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// authored clips&lt;/span&gt;
  &lt;span class="nx"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// pure function of time&lt;/span&gt;
  &lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;camera&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shape is the same every time: &lt;em&gt;time in, frame out, no memory of the previous call&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duration turned out to be a separate problem from seeking
&lt;/h3&gt;

&lt;p&gt;This surprised me. The render engine needs a positive total duration before it captures a single frame, or capture fails outright. A GSAP timeline reports its own length, so that case is free. The others aren't.&lt;/p&gt;

&lt;p&gt;CSS duration is inferred from the longest &lt;code&gt;animation-delay&lt;/code&gt; + &lt;code&gt;duration&lt;/code&gt; × finite iteration count. WAAPI comes from the longest effect's &lt;code&gt;getComputedTiming().endTime&lt;/code&gt;. Lottie reads the asset's native length, which is finite even when &lt;code&gt;loop: true&lt;/code&gt;. And Three.js is &lt;strong&gt;not inferable at all&lt;/strong&gt; — the adapter only forwards time, it doesn't inspect your scene for an &lt;code&gt;AnimationClip&lt;/code&gt;. So a Three.js composition must declare &lt;code&gt;data-duration&lt;/code&gt; on its root, and the linter errors when it doesn't.&lt;/p&gt;

&lt;p&gt;Two capabilities that felt like one: &lt;em&gt;where am I on the timeline&lt;/em&gt; and &lt;em&gt;how long is the timeline&lt;/em&gt;. Every adapter answers the first. Only some can answer the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you catch a desync before it reaches a delivered video?
&lt;/h2&gt;

&lt;p&gt;Sample the frames a human would never scrub to. Three passes, in order of cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx hyperframes check                      &lt;span class="c"&gt;# lint, runtime, layout, motion, contrast&lt;/span&gt;
npx hyperframes snapshot &lt;span class="nt"&gt;--at&lt;/span&gt; 2.4,6.1,9.8  &lt;span class="c"&gt;# eyeball the seams, not the beats&lt;/span&gt;
npx hyperframes render                     &lt;span class="c"&gt;# only after the frames look right&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check&lt;/code&gt; catches the mechanical violations — a missing duration source, a &lt;code&gt;repeat&lt;/code&gt; that overshoots, a &lt;code&gt;set&lt;/code&gt; on a clip from a later scene. Snapshots catch the silent class: a transformed element that's still inline and therefore invisible, a full-screen background painted on the root instead of a full-bleed child (which previews correctly and renders black), a pulsing decorative that clears its neighbor at rest and collides at peak scale.&lt;/p&gt;

&lt;p&gt;There's also an audit script that walks every registered timeline, enumerates the tweens, samples bounding boxes and writes an animation map — useful for finding dead zones and inconsistent stagger after the fact.&lt;/p&gt;

&lt;p&gt;Almost every bug that survived to a delivered frame had the same signature: preview looked right, render didn't. Preview plays forward, in order, in one process. The renderer doesn't do any of those three things. Anything you validate only by pressing play, you haven't validated.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>animation</category>
      <category>webdev</category>
      <category>gsap</category>
    </item>
    <item>
      <title>Why a CSS diff reads right and renders wrong</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:21:40 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/why-a-css-diff-reads-right-and-renders-wrong-3ah1</link>
      <guid>https://dev.to/kyisaiah47/why-a-css-diff-reads-right-and-renders-wrong-3ah1</guid>
      <description>&lt;p&gt;I shipped a mobile fix for one of our tenant sites last week — coi-tracking, the first real app we stood back up after the catalog teardown. The change was small: restore some breakpoint rules that had gone missing in a port, and un-hide a few elements that only exist once on desktop. Reading the diff, every line was obviously correct. Reading the site at 390px before the fix, it was obviously broken. Those two facts had been true at the same time for a while.&lt;/p&gt;

&lt;p&gt;That gap — a change that reads right in the diff and wrong in the browser — is the thing I've spent the most effort building a habit around, because I'm one person and there is no second pair of eyes between my editor and production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a CSS diff read right and render wrong?
&lt;/h2&gt;

&lt;p&gt;Because a diff shows you the rule you changed, and the browser shows you the rule that won. Those are different questions, and only one of them is answered by the text in front of you.&lt;/p&gt;

&lt;p&gt;A media query block is a self-contained, readable thing. When it's present, it reads as deliberate. When it's &lt;em&gt;absent&lt;/em&gt;, it reads as nothing at all — there's no line to notice. Deletions in CSS are silent in a way deletions in TypeScript are not. Remove a function and something fails to compile. Remove a &lt;code&gt;@media (max-width: 768px)&lt;/code&gt; block and the desktop layout stays pixel-identical, the build passes, and the site is broken at a width you weren't looking at.&lt;/p&gt;

&lt;p&gt;The coi-tracking landing came through our Framer→Next capture-and-own pipeline — we license a template, capture the live rendered DOM and CSS, and port it into code we own so it's ours to change forever. That pipeline is great, and it is also exactly the kind of transform where breakpoint blocks and &lt;code&gt;display&lt;/code&gt; toggles get dropped one at a time without any of the surviving lines looking wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three shapes that keep doing this
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Missing breakpoint blocks.&lt;/strong&gt; Desktop unaffected, mobile stacked wrong. Zero signal in the diff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orphaned &lt;code&gt;display: none&lt;/code&gt;.&lt;/strong&gt; An element hidden at one width on the assumption that a different element covers that case — and then the covering element gets removed or renamed. Now nothing renders there at all. The diff shows a deletion of "unused" markup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reveal-on-scroll that never reveals.&lt;/strong&gt; The element is in the DOM, present in view-source, passes any test that queries for it, and is invisible to a human forever because its opacity never got animated to 1.&lt;/p&gt;

&lt;p&gt;That third one is the meanest, so it's worth taking apart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm393utxzbf8hodr7jxbn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm393utxzbf8hodr7jxbn.png" alt="Why a CSS diff reads right and renders wrong — code" width="800" height="782"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep a scroll reveal from leaving content invisible?
&lt;/h2&gt;

&lt;p&gt;Make the failure mode "shows too early" instead of "never shows," and add a hard failsafe that ignores your own logic.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IntersectionObserver&lt;/code&gt; reveals fail in three ways I now check for by reflex:&lt;/p&gt;

&lt;p&gt;A negative bottom &lt;code&gt;rootMargin&lt;/code&gt; shrinks the observation box, so the callback fires &lt;em&gt;after&lt;/em&gt; the element is already visible to the reader — the animation plays late, or looks like a flicker. A positive margin fires early, which is what you actually want.&lt;/p&gt;

&lt;p&gt;A large hide-transform (&lt;code&gt;translateY(80px)&lt;/code&gt;, a big &lt;code&gt;scale&lt;/code&gt;) means the rect the observer measures is the &lt;em&gt;post-transform&lt;/em&gt; rect. If your transform pushes the element far enough, the observer's geometry and the layout geometry disagree, and the intersection can simply never happen.&lt;/p&gt;

&lt;p&gt;And anything above the fold on first paint may never trigger a scroll event at all.&lt;/p&gt;

&lt;p&gt;Every one of those leaves content permanently invisible, which is the worst possible outcome for a marketing page — worse than an ugly animation, worse than no animation.&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;// reveal.ts — the version I ship now&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;observeReveal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Above the fold on load: reveal immediately, never wait for a scroll.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revealed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isIntersecting&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revealed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// Positive margin: fire BEFORE it enters view, not after.&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rootMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0px 0px 15% 0px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Failsafe: whatever the observer thinks, this content becomes&lt;/span&gt;
  &lt;span class="c1"&gt;// visible. A broken animation beats a blank section.&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&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="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;revealed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hide state pairs with a &lt;em&gt;small&lt;/em&gt; transform — 16px, not 80 — so layout and observer geometry stay in agreement:&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="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-reveal&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&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;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-reveal&lt;/span&gt;&lt;span class="o"&gt;][&lt;/span&gt;&lt;span class="nt"&gt;data-revealed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"true"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;setTimeout&lt;/code&gt; is the part I'd have argued against a year ago as sloppy. I don't anymore. It converts an entire class of invisible-content bugs into a mild timing artifact, and it costs three lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a browser check look like when there's no QA team?
&lt;/h2&gt;

&lt;p&gt;It's a script that opens real Chrome at the widths you actually ship at and saves a screenshot of each one, and you look at them.&lt;/p&gt;

&lt;p&gt;I already had this lying around for a different reason. Our demo recordings are driven by Playwright — a real browser walking the full product flow with no human in the loop, which I wrote up separately. Once you have a harness that can drive a live page and wait for it to settle deterministically instead of guessing at timeouts, pointing it at a landing page across breakpoints is a ten-line job.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WIDTHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;390&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1728&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;width&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;WIDTHS&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle&lt;/span&gt;&lt;span class="dl"&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;screenshot&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="s2"&gt;`shots/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;width&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="na"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No pixel diffing, no baselines to maintain, no flaky visual-regression suite that I'd start ignoring within two weeks. Four images I look at with my eyes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'm actually looking for
&lt;/h3&gt;

&lt;p&gt;Large blocks of empty vertical space — that's an element that failed to render or is sitting at opacity 0. A section that appears at 1280 and vanishes at 768 without a mobile counterpart taking its place. Text that wraps to five lines in a container built for two. And any image slot that's a blank rectangle, which is the failure mode after an asset purge.&lt;/p&gt;

&lt;p&gt;That last one is worth calling out. I recently stripped a pile of unused Framer template images and the dead &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; refs pointing at them, and deleted the orphaned components that came with them. On paper that's the safest kind of change — removing code nothing uses. In practice "nothing uses it" is a claim about the whole render tree at every viewport, and the diff can only show you the deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which changes get a browser check and which don't?
&lt;/h2&gt;

&lt;p&gt;Anything that touches a breakpoint, toggles visibility, deletes an asset, or moves a route. Everything else I merge on the diff.&lt;/p&gt;

&lt;p&gt;That's a deliberately narrow list, because a rule I apply to every change is a rule I'll stop applying. Backend logic, schema changes, agent prompts, API routes — I trust tests and the diff. But the four categories above share a property: their failure is invisible in text and obvious in a browser.&lt;/p&gt;

&lt;p&gt;Route moves earned their spot recently. I folded &lt;code&gt;apps/landing&lt;/code&gt; into the Artemis app so the marketing site and the builder live in one place — landing at the root, builder at &lt;code&gt;/create&lt;/code&gt;. Every individual file move was correct. Whether the &lt;em&gt;combination&lt;/em&gt; of moves left a working site is not a question source code answers. Our &lt;code&gt;deploy.sh&lt;/code&gt; verify step checks real URLs after deploy, including accepting the post-teardown redirects from the retired catalog, and it's the only thing that actually knows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about bugs that only exist in a browser at all?
&lt;/h2&gt;

&lt;p&gt;Some errors have no source-code representation, and hydration is the clearest example.&lt;/p&gt;

&lt;p&gt;Artemis had a hydration mismatch I could not see anywhere in the code, because it wasn't in the code — it came from the DOM being modified before React hydrated, in a real browser with real extensions installed. Server HTML and client HTML disagreed for reasons no file in the repo mentioned. The fix was pinning a vendored dependency to a known-good version and gating the injected case explicitly. No amount of diff-reading would have found it; it needed a browser with the extension installed, which is to say, a normal user's browser.&lt;/p&gt;

&lt;p&gt;This is the same principle underneath all of it, and it's why Kynth Apollo — our capabilities layer, where every hard problem gets solved once and reused — holds the browser harness alongside everything else. Artemis assembles apps out of those finished pieces. A piece that renders correctly at one width and disappears at another isn't finished, and there's exactly one place you can find that out.&lt;/p&gt;

&lt;p&gt;Open the browser. Four widths. Look at the pictures.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>playwright</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Recording product demos with Playwright and no human</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:21:06 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/recording-product-demos-with-playwright-and-no-human-62f</link>
      <guid>https://dev.to/kyisaiah47/recording-product-demos-with-playwright-and-no-human-62f</guid>
      <description>&lt;p&gt;I record full product demos with no human touching the keyboard — a real Chrome instance drives the app end to end, clicks through the flow, and a recorder captures every frame. The interesting part turned out not to be the clicking. It was deciding &lt;em&gt;when&lt;/em&gt; the page was actually ready for the next step, and realizing the thing I'd built was a state machine wearing a recorder's clothes.&lt;/p&gt;

&lt;p&gt;Here's what I learned building it for Kynth, where the demos have to look hand-driven even though nobody's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do fixed timeouts break automated demos?
&lt;/h2&gt;

&lt;p&gt;Fixed timeouts break because they encode a guess about the network and the machine, and both change under you. The classic version is &lt;code&gt;await page.waitForTimeout(2000)&lt;/code&gt; after a navigation. On my laptop with a warm cache, 2000ms is 1500ms of dead air on camera. On a cold CI runner rendering the same page, 2000ms is 400ms short and I capture a spinner mid-flight — a demo that pauses awkwardly, then jump-cuts into a half-loaded dashboard.&lt;/p&gt;

&lt;p&gt;You can't win this by tuning the number. A bigger timeout wastes screen time on every fast run; a smaller one flakes on every slow one. The number is wrong in both directions at once because it's answering the wrong question. I don't care that 2 seconds passed. I care that the page stopped changing.&lt;/p&gt;

&lt;p&gt;So the whole recorder is built around one idea: &lt;strong&gt;settle-detection&lt;/strong&gt;. Instead of waiting a duration, wait for the DOM to go quiet.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "settled" actually means
&lt;/h3&gt;

&lt;p&gt;A page is settled when nothing meaningful has mutated for a short quiet window. I watch the DOM with a &lt;code&gt;MutationObserver&lt;/code&gt; injected into the page, reset a timer on every mutation batch, and consider the page ready when the timer survives, say, 350ms untouched — plus network idle, because a settled DOM that's still waiting on an XHR is about to un-settle.&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;// runs inside the page via page.evaluate&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;quietMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&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;now&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;obs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&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="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&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;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;characterData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&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;now&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;tick&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&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;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;quietMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// hard ceiling&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;maxMs&lt;/code&gt; ceiling matters more than it looks. Some pages never fully go quiet — a looping CSS animation, a polling badge, an SSE connection nudging a counter. Without the ceiling, settle-detection waits forever for a page that's &lt;em&gt;never&lt;/em&gt; going to be still. So it's settle-with-a-deadline: quiet wins if it can, the clock wins if it can't. That single fallback is the difference between a recorder that's robust and one that hangs on a &lt;code&gt;&amp;lt;marquee&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is a demo recorder really a state machine?
&lt;/h2&gt;

&lt;p&gt;A recorder is a state machine because every step depends entirely on the exact state the last step left the app in, and the hard bugs all live in the transitions, not the actions. I didn't set out to build one. I started with a flat script — click, wait, click, wait — and it worked until it didn't, and every time it broke it was because step N assumed a screen that step N-1 didn't actually produce.&lt;/p&gt;

&lt;p&gt;Once I named the states, the failures got legible. The recorder moves through explicit nodes — &lt;code&gt;boot&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, &lt;code&gt;demo-open&lt;/code&gt;, &lt;code&gt;flow-step-k&lt;/code&gt;, &lt;code&gt;result&lt;/code&gt;, &lt;code&gt;teardown&lt;/code&gt; — and each transition has three parts: an action (the click), a settle (wait for quiet), and an assertion (prove we landed where we meant to). If the assertion fails, I don't blindly continue into a black recording. I know exactly which edge broke.&lt;/p&gt;

&lt;h3&gt;
  
  
  The assertion is the part people skip
&lt;/h3&gt;

&lt;p&gt;The temptation is to trust that clicking a button lands you on the next screen. It usually does. But "usually" is how you get a 90-second video where seconds 40 through 55 are a modal that failed to open, recorded in full confidence. So every transition ends by asserting the target state exists before the camera is allowed to move on:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;transition&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="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;act&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="c1"&gt;// e.g. click "Run demo"&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;settle&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="c1"&gt;// DOM quiet + network idle&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&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="c1"&gt;// target state visible?&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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StateError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&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;Modeling it as a machine also gave me retries for free. A flaky edge — a cold Supabase connection that 500s the first time, an auth redirect that resolves a beat late — can retry &lt;em&gt;just that transition&lt;/em&gt; from a known state, instead of tearing down and re-recording the whole demo from &lt;code&gt;boot&lt;/code&gt;. When you're rendering these for real, re-running one edge versus the whole flow is the difference between a 3-second recovery and starting over.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep an automated flow from looking robotic?
&lt;/h2&gt;

&lt;p&gt;You add human-shaped pacing back in &lt;em&gt;on purpose&lt;/em&gt;, after settle-detection has removed the accidental pauses. This felt backwards at first. I spent all this effort deleting dead air, then deliberately put some back — but the two kinds of waiting are different. Settle-detection removes the pauses that come from &lt;em&gt;not knowing&lt;/em&gt; the page is ready. Then I add small, intentional dwell before a click so a viewer's eye can land on the button before it depresses.&lt;/p&gt;

&lt;p&gt;The rule I settled on: settle to zero, then dwell to taste. Settle-detection guarantees the &lt;em&gt;floor&lt;/em&gt; — we're never recording a spinner. The dwell is a fixed, small, deliberate beat layered on top, and because it sits on a known-quiet page it reads as a person deciding, not a script stalling. Crucially the dwell is constant across machines; the settle absorbs all the variance. Fast runner or slow, the &lt;em&gt;rhythm&lt;/em&gt; of the final video is identical, which is the whole point when these get cut together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Settle-detection is a recording primitive, not a test trick
&lt;/h3&gt;

&lt;p&gt;The waiting strategy that makes a good recorder is the same one that makes a good end-to-end test, which is why this generalizes past demos. A test that waits for settle instead of a timeout is a test that doesn't flake and doesn't waste minutes of CI on padding. The demo harness and the test harness want the identical thing: proof that the app reached a state, not proof that time passed.&lt;/p&gt;

&lt;p&gt;That overlap is why the recorder lives next to the app in the Turborepo monorepo rather than in some separate video project. It drives the real Next 16 app against real Supabase and Stripe — the same stack a user hits. When FetchDue went live the weekend of July 18, the recorded run that proves it works — a real reply classified, a real answer drafted, a real email delivered — came out of this exact harness driving the live product. No mock, no staged screen. The camera watched Chrome do the thing a customer does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building this
&lt;/h2&gt;

&lt;p&gt;Stop tuning the timeout and start watching the DOM. Almost every flaky wait in an automated flow is a fixed duration standing in for a state you could have observed directly. Replace it with settle-with-a-deadline — quiet window plus a hard ceiling — and the whole class of "worked on my machine, flaked in CI" problems goes away, because you stopped guessing at time and started measuring readiness.&lt;/p&gt;

&lt;p&gt;The state-machine framing is the other half. The moment you write down the states and make each transition assert its target, your failures tell you which edge broke instead of leaving you a black video and a shrug. And you get per-edge retries as a bonus, which is what makes recording against a real backend — where a cold connection occasionally hiccups — tolerable instead of maddening.&lt;/p&gt;

&lt;p&gt;This recorder is one of the pieces inside Kynth Apollo, the layer where I solve a hard problem once and never solve it again. Every demo, every product screenshot, every proof-it-works run pulls from the same harness now. That's the quiet luxury of building this way: the second time you need a driven browser, you're not building — you're composing.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>automation</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Getting paid is a state-machine problem</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Thu, 16 Jul 2026 14:21:08 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/getting-paid-is-a-state-machine-problem-142j</link>
      <guid>https://dev.to/kyisaiah47/getting-paid-is-a-state-machine-problem-142j</guid>
      <description>&lt;p&gt;I build a lot of things on top of one shared Core — a capabilities layer where every hard problem gets solved once and reused. The wedge I keep coming back to is the least glamorous thing a small business deals with: getting paid. Invoices go out, some get paid, some get ignored, a few get reversed weeks later. I used to think the product was the invoice. It turned out the product is everything that happens &lt;em&gt;after&lt;/em&gt; you hit send.&lt;/p&gt;

&lt;p&gt;Here's what I learned modeling that middle as real engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you model an invoice that might get paid, ignored, or reversed?
&lt;/h2&gt;

&lt;p&gt;Model it as a state machine, not a &lt;code&gt;paid&lt;/code&gt; boolean. The first version of the invoices schema in Supabase had a &lt;code&gt;status&lt;/code&gt; column with three values — &lt;code&gt;draft&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;paid&lt;/code&gt; — and it fell apart the first time a payment came back as a chargeback three weeks after the money had "arrived."&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a &lt;code&gt;paid&lt;/code&gt; boolean is a trap
&lt;/h3&gt;

&lt;p&gt;A boolean encodes one transition and assumes it's terminal. But money moving is not a one-way door. A paid invoice can be disputed. A dispute can be won or lost. A lost dispute leaves you needing to recover funds you already counted as revenue. If &lt;code&gt;paid&lt;/code&gt; is the end of your graph, you have nowhere to put any of that, so it leaks into ad-hoc columns and &lt;code&gt;if&lt;/code&gt; statements scattered across the app.&lt;/p&gt;

&lt;p&gt;The tell is when you find yourself writing &lt;code&gt;if (invoice.paid &amp;amp;&amp;amp; invoice.disputed &amp;amp;&amp;amp; !invoice.recovered)&lt;/code&gt;. That's three booleans pretending to be one state, and every new edge case adds another boolean and doubles the combinations you have to reason about.&lt;/p&gt;

&lt;h3&gt;
  
  
  The states that actually matter
&lt;/h3&gt;

&lt;p&gt;I rewrote it as an explicit lifecycle where every value is a real place an invoice can sit, and every transition is something that actually happens in the world:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;InvoiceState&lt;/span&gt; &lt;span class="o"&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;draft&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;sent&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;viewed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;        &lt;span class="c1"&gt;// opened the link, hasn't paid&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overdue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;       &lt;span class="c1"&gt;// past due date, still in chase&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&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;disputed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;      &lt;span class="c1"&gt;// chargeback opened&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dispute_won&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;dispute_lost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;// funds clawed back&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in_recovery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;   &lt;span class="c1"&gt;// chasing money we already lost&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recovered&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;written_off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// transitions are a lookup, not a pile of ifs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NEXT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;InvoiceState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;InvoiceState&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;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;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overdue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;viewed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;overdue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;overdue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;written_off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disputed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;disputed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dispute_won&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dispute_lost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;dispute_won&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;dispute_lost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in_recovery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;written_off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;in_recovery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;recovered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;written_off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;recovered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;dispute_won_terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;written_off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="na"&gt;recovered_terminal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;never&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;Once the states were explicit, a lot of downstream code got boring in the best way. The aging view — the rows that show which invoices are drifting from &lt;code&gt;sent&lt;/code&gt; toward &lt;code&gt;overdue&lt;/code&gt; — is just a query grouped by state and time-in-state. When I replaced an old placeholder in the Mercury workspace with the real KynthMark org avatar and invoice aging rows, the rows had something honest to render because the state model underneath them was honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a chase cadence, and how do you build one?
&lt;/h2&gt;

&lt;p&gt;A chase cadence is a scheduled ladder of reminders that escalates as an invoice ages, and the thing I learned is that it should be data, not code. My first instinct was to write the follow-up logic imperatively — send at day 3, day 7, day 14, change the tone each time. That works until the second product needs a slightly different ladder and you're copy-pasting &lt;code&gt;setTimeout&lt;/code&gt;-shaped logic.&lt;/p&gt;

&lt;p&gt;So the cadence is rows. Each rung is a record: how many days after the due date it fires, which channel, and which tone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offsetDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"friendly_heads_up"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offsetDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gentle_nudge"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offsetDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"firm_reminder"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offsetDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"tone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"direct"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offsetDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"final_notice"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I redid the cadence display in the workspace, I rendered the ladder literally as rows — one line per rung — because that's what it &lt;em&gt;is&lt;/em&gt; in the data. The UI stopped being a diagram I hand-drew and became a view of the table. That's a small thing, but it's the through-line of the whole Core: when the model is right, the interface is a window onto it instead of a second source of truth you have to keep in sync.&lt;/p&gt;

&lt;p&gt;The other reason cadence-as-data matters: a rung is a scheduled effect, and scheduled effects are exactly what breaks when a serverless instance dies mid-flight. If you fire these from a "run after the response" hook and the box goes away, the reminder is orphaned. The fix is to give each scheduled send a lease and run a reaper that moves anything whose lease lapsed into a terminal state — but that's a whole article on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you handle chargebacks without special-casing every provider?
&lt;/h2&gt;

&lt;p&gt;Route payments through a capability interface instead of calling the Stripe SDK directly. This is the single decision that keeps the ugly middle from metastasizing across the codebase. In the Core, apps don't import &lt;code&gt;stripe&lt;/code&gt;. They call a &lt;code&gt;payments&lt;/code&gt; capability, and the capability resolves to a driver based on which environment keys are present.&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;// apps never see the vendor — they see the capability&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolvePayments&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;PaymentsDriver&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stripeDriver&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// more drivers slot in here by key presence&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;stubPayments&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// first-class stub, builds with zero creds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PaymentsDriver&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cents&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ChargeResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;onDispute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DisputeEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RefundResult&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;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stub matters more than it looks. Because a missing key falls back to a first-class stub instead of throwing, every app in the monorepo builds and renders with no credentials set. A demo can run the entire invoice → chase → dispute → recovery flow against the stub driver, deterministically, without a real Stripe account in the loop. That's what lets the Playwright automation drive the whole thing in a real browser end to end — the money side is real code paths, just with a driver that never touches a network.&lt;/p&gt;

&lt;p&gt;And when a chargeback lands, it enters through &lt;code&gt;onDispute&lt;/code&gt; in exactly one place. The capability translates the vendor's dispute event into the &lt;code&gt;disputed&lt;/code&gt; transition from my own state machine. Nothing downstream knows or cares that it was Stripe. If I ever swap providers, the state graph doesn't move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the ugly middle the actual product?
&lt;/h2&gt;

&lt;p&gt;Because the invoice is a commodity and the recovery is not. Anyone can render a PDF with a total on it. The hard, valuable part is the state machine that knows an invoice is drifting overdue, the cadence ladder that escalates on its own, and the recovery path for money that already left. That middle is unglamorous, full of edge cases, and exactly where small businesses lose real money — which is why it's worth building carefully.&lt;/p&gt;

&lt;p&gt;Framing it as engineering changed how I think about the whole thing. Once the payments capability, the invoice lifecycle, and the cadence ladder were solved once in the Core, the next product that needed to get someone paid didn't start from scratch. It snapped together from finished pieces, and I got to spend my time on the parts that were actually new. That's the quiet payoff of solving the hard thing once: building starts to feel less like building and more like composing. The ugly middle, done properly, is the part you never have to write twice.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>startup</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>What 1,000 invoices a month actually costs across five document-AI APIs</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Fri, 10 Jul 2026 18:34:06 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/what-1000-invoices-a-month-actually-costs-across-five-document-ai-apis-2613</link>
      <guid>https://dev.to/kyisaiah47/what-1000-invoices-a-month-actually-costs-across-five-document-ai-apis-2613</guid>
      <description>&lt;p&gt;Pricing pages for document-extraction APIs are written to win a different comparison than the one you actually need. Per-page rates hide minimums. Credit systems hide per-document math. So here's the arithmetic for one concrete, boring workload: &lt;strong&gt;1,000 single-page invoices a month&lt;/strong&gt;, at each vendor's published July-2026 pricing. (I sell one of these APIs — disclosure at the bottom — but every number here is from the vendor's own pricing page.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Published rate&lt;/th&gt;
&lt;th&gt;1,000 invoices/mo&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Textract (AnalyzeExpense)&lt;/td&gt;
&lt;td&gt;$0.01/page&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You build the pipeline: retries, failures, schema mapping. Every attempt bills, including failed ones.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Document AI (Invoice parser)&lt;/td&gt;
&lt;td&gt;$0.01/page&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same deal. (Their Bank Statement parser is $0.75/classified doc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure Document Intelligence (prebuilt)&lt;/td&gt;
&lt;td&gt;$0.01/page&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Same again — the hyperscalers are interchangeable here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LlamaParse&lt;/td&gt;
&lt;td&gt;$1.25/1k pages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$1.25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It's a &lt;em&gt;parser&lt;/em&gt;, not an extractor: structure back, not validated invoice fields. Budget your own extraction layer (and its LLM bill) on top.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Veryfi&lt;/td&gt;
&lt;td&gt;$0.16/invoice&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$160 — except no.&lt;/strong&gt; Production starts at a &lt;strong&gt;$500/mo minimum&lt;/strong&gt; buying &amp;lt;5k docs&lt;/td&gt;
&lt;td&gt;At 1,000 invoices your effective rate is &lt;strong&gt;$0.50/doc&lt;/strong&gt;, not $0.16.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kynth Core (mine)&lt;/td&gt;
&lt;td&gt;$0.08/invoice&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$80&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Charged only when extraction succeeds; no minimum, no commitment.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The three questions that actually price this category
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. What does a failure cost?&lt;/strong&gt; The per-page APIs bill per attempt. If your docs are ugly — photographed receipts, scanned faxes, tables that explode — your effective per-&lt;em&gt;successful&lt;/em&gt;-document rate is higher than the sticker. That's why "cost per correctly extracted document" (price ÷ accuracy) is the only number worth comparing, and why I publish a reproducible accuracy benchmark alongside this post — including the suites where the hyperscalers beat me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What does the minimum cost?&lt;/strong&gt; Veryfi's per-doc rates look adjacent to mine until the commitment line. Minimums are a bet the vendor makes you place on your own volume. Process 800 invoices a month and you're paying for 5,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Who owns the pipeline?&lt;/strong&gt; $10/month at Textract is real if your engineering time is free. The hyperscaler APIs hand you fields; everything around them — schema validation, retries, normalization, the 3am alert when a new invoice layout breaks parsing — is yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure and receipts
&lt;/h2&gt;

&lt;p&gt;Kynth Core is my product, so weight my row accordingly. What I can offer instead of neutrality is reproducibility: the accuracy benchmark behind these claims is an MIT-licensed harness (public datasets, pinned revisions, raw responses committed, fork-safe CI) — re-run every number with your own keys, or re-score my committed responses with no keys at all. Links: &lt;a href="https://github.com/kyisaiah47/doc-extract-bench" rel="noopener noreferrer"&gt;benchmark repo&lt;/a&gt; · &lt;a href="https://api.kynth.studio/benchmarks" rel="noopener noreferrer"&gt;live scores&lt;/a&gt; · &lt;a href="https://api.kynth.studio/guides/invoice-ocr-api-pricing" rel="noopener noreferrer"&gt;pricing sources&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>api</category>
      <category>ocr</category>
      <category>documentai</category>
      <category>saas</category>
    </item>
    <item>
      <title>I benchmarked my document-extraction API against Textract and Google DocAI — on public datasets, in public CI</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:04:01 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/i-benchmarked-my-document-extraction-api-against-textract-and-google-docai-on-public-datasets-in-3gdk</link>
      <guid>https://dev.to/kyisaiah47/i-benchmarked-my-document-extraction-api-against-textract-and-google-docai-on-public-datasets-in-3gdk</guid>
      <description>&lt;p&gt;Vendor benchmarks are rightly distrusted: pick a friendly dataset, tune on the test set, round up, publish. I sell a document-extraction API (Kynth Core), I wanted to publish accuracy numbers, and I couldn't find a form of "trust me" that I would accept from someone else. So the benchmark became a repo instead of a claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules I couldn't let myself break
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Public datasets only, pinned.&lt;/strong&gt; FATURA for invoices (synthetic — disclosed loudly), SROIE + CORD-v2 for receipts, FinTabNet for tables, and the Bankstatemently open benchmark for statements — where the ground truth is held &lt;em&gt;server-side by a third party&lt;/em&gt;, so I literally cannot tune against it. Every dataset pinned by revision with SHA-256s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subsets pre-registered.&lt;/strong&gt; The seeded selection of document IDs was committed &lt;em&gt;before&lt;/em&gt; any API was called. No document was dropped after seeing results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fuzzy matching in headline numbers.&lt;/strong&gt; Amounts to the cent, dates exact (ISO), strings after whitespace/punctuation normalization. ANLS is a secondary column only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw responses committed.&lt;/strong&gt; Every vendor's verbatim API output is in the repo — anyone can re-score everything with zero API keys (&lt;code&gt;pnpm replay &amp;amp;&amp;amp; pnpm report&lt;/code&gt;), or challenge my adapters' normalization line by line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI runs it, not me.&lt;/strong&gt; Fork PRs run keyless replay-scoring only, so nobody's keys leak.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Kynth Core&lt;/th&gt;
&lt;th&gt;AWS Textract&lt;/th&gt;
&lt;th&gt;Google DocAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invoices (FATURA, n=100)&lt;/td&gt;
&lt;td&gt;field accuracy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99.4%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;92.2%&lt;/td&gt;
&lt;td&gt;93.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receipts (SROIE, n=100)&lt;/td&gt;
&lt;td&gt;field accuracy&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;88.8%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;68.8%&lt;/td&gt;
&lt;td&gt;52.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receipt line items (CORD, n=100)&lt;/td&gt;
&lt;td&gt;F1&lt;/td&gt;
&lt;td&gt;41.9%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;77.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;36.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receipt totals (CORD, n=100)&lt;/td&gt;
&lt;td&gt;accuracy&lt;/td&gt;
&lt;td&gt;67.1%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95.5%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;82.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables (FinTabNet, n=100)&lt;/td&gt;
&lt;td&gt;TEDS&lt;/td&gt;
&lt;td&gt;0.791&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.836&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.365&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Where Kynth lost, plainly:&lt;/strong&gt; Textract beats it decisively on CORD line-item extraction (77.1% vs 41.9% F1) and receipt totals from the same set, and edges it on table structure (0.836 vs 0.791 TEDS) — where Kynth also logged 14 outright failures on hard tables, counted against it rather than dropped. Kynth is also roughly 10× slower per document than Textract. Kynth wins where fields need semantic understanding (invoice fields, messy scanned receipts); the hyperscalers win on dense structural extraction. That trade is the honest takeaway.&lt;/p&gt;

&lt;p&gt;Notes that keep the table honest: Textract has no bank-statement product (N/A, not zero). Bank statements are pending entirely — the third-party evaluator currently 400s on its own published files; the n=5 parses are committed and will score when it's fixed. Veryfi and LlamaParse adapters are implemented but unscored — no credentials this cycle; I wasn't going to pay Veryfi's $500/mo minimum to benchmark a competitor. FATURA is synthetic, which flatters everyone equally, but flatters.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd want you to take from this
&lt;/h2&gt;

&lt;p&gt;Not "Kynth wins" — on three of five rows it doesn't. The claim is narrower: &lt;strong&gt;flat per-document pricing, billed only on success, with accuracy you can reproduce&lt;/strong&gt; is a different product shape than per-page-per-attempt with a pipeline you own. The repo is MIT: &lt;a href="https://github.com/kyisaiah47/doc-extract-bench" rel="noopener noreferrer"&gt;doc-extract-bench&lt;/a&gt;. PRs that improve &lt;em&gt;competitors'&lt;/em&gt; adapters are especially welcome — that's the failure mode a vendor benchmark can't recover from on its own.&lt;/p&gt;

&lt;p&gt;Live scores: &lt;a href="https://api.kynth.studio/benchmarks" rel="noopener noreferrer"&gt;api.kynth.studio/benchmarks&lt;/a&gt;. The engine behind it runs 73 production apps; that story is &lt;a href="https://kynth.studio/writing" rel="noopener noreferrer"&gt;on my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ocr</category>
      <category>benchmarking</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How one person runs a whole studio of production apps</title>
      <dc:creator>Isaiah Kim</dc:creator>
      <pubDate>Thu, 09 Jul 2026 01:02:41 +0000</pubDate>
      <link>https://dev.to/kyisaiah47/how-one-person-runs-73-production-apps-ie8</link>
      <guid>https://dev.to/kyisaiah47/how-one-person-runs-73-production-apps-ie8</guid>
      <description>&lt;p&gt;I maintain a whole studio of production web apps by myself. Not landing pages — real apps with auth, billing, a database, their own domains, their own UI, and real AI features behind a paywall. People assume the trick is that they're thin. It's the opposite: the only way one person keeps a studio of real apps alive is to make the apps thin and the &lt;em&gt;engine&lt;/em&gt; thick. Almost everything an app does, it borrows.&lt;/p&gt;

&lt;p&gt;This is a teardown of that engine. I'm writing it because the engine is now a product — &lt;a href="https://api.kynth.studio" rel="noopener noreferrer"&gt;api.kynth.studio&lt;/a&gt;, which I call Kynth Core — and a whole studio of apps in production is the most honest load test I can offer for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;A single app is easy. The cost of software isn't the first instance, it's the Nth. Every app wants the same twelve things: sign-in, a session, a user row, a Stripe subscription, a webhook to reconcile it, analytics, SEO metadata, a design system, an AI call with a budget on it, and a way to ship without babysitting a pipeline. If each app owns its own copy of those twelve things, then N apps is 12N surfaces to patch when Stripe rotates an API version or Next ships a breaking change. That number is where solo maintenance dies.&lt;/p&gt;

&lt;p&gt;So the design rule is blunt: &lt;strong&gt;an app is allowed to own its domain logic and its screens, and nothing else.&lt;/strong&gt; Everything horizontal lives once, in a shared package, and every app imports it. When I fix auth, I fix it for the entire studio in one commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monorepo and the shared packages
&lt;/h2&gt;

&lt;p&gt;It's a Turborepo monorepo. The apps are Next.js. The load-bearing part isn't the apps directory — it's &lt;code&gt;packages/&lt;/code&gt;. A few of them do most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@kynth/analytics&lt;/code&gt;&lt;/strong&gt; — one React component, mounted in every app's root layout. It initializes PostHog, fires &lt;code&gt;$pageview&lt;/code&gt;, and — this is the part that pays for itself — self-labels every event with an &lt;code&gt;app&lt;/code&gt; super-property derived from the host. A signup on one app's subdomain and a signup on any other land in the same event stream, correctly attributed, with zero per-app wiring. It also fires the funnel events and the ads conversion pixel on the same shared success boundary. No app writes analytics code. They inherit it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@kynth/shell&lt;/code&gt; / &lt;code&gt;@kynth/layouts&lt;/code&gt;&lt;/strong&gt; — the design system as components, not copied CSS. A three-zone workspace layout, the modals, the toasts, the form primitives, the dashboard cards. Each app sets exactly one variable — &lt;code&gt;--shell-accent&lt;/code&gt;, its identity hue — and the whole chrome recolors. The house chrome is monochrome on purpose so that one variable is the only thing an app has to decide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@kynth/seo&lt;/code&gt;&lt;/strong&gt; — metadata, JSON-LD, sitemaps, and the cross-app link graph, generated from a single catalog file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That catalog file is the spine. &lt;code&gt;catalog.ts&lt;/code&gt; is the source of truth for what apps exist: slug, display name, the accent hue, the marketing copy. The build reads it; the SEO package reads it; the launcher reads it. When I add an app, I add a catalog entry, and the rest of the platform notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  One database, one deploy
&lt;/h2&gt;

&lt;p&gt;Two decisions do more for maintainability than any code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One Supabase project for all of them.&lt;/strong&gt; Every app shares one auth pool and one Postgres. A user is a user across the whole suite; I don't run a database per app or reconcile a pile of separate identity systems. Row-level security and a per-app column keep each app's data its own. The operational win is that there is exactly one place to look when something is wrong with data, and exactly one migration surface.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One Vercel project, not one per app.&lt;/strong&gt; This is the counterintuitive one. The apps used to deploy as separate Vercel projects — dozens of dashboards, dozens of env-var sets, dozens of build configs drifting apart. Now they consolidate into a single deployment that routes by &lt;code&gt;Host&lt;/code&gt; header: the same running project serves every subdomain and decides which app to render from the hostname. I deploy once. Rolling an app back is re-pointing a subdomain, not a redeploy. The 12N surface problem collapses because there's one of most things.&lt;/p&gt;

&lt;p&gt;Shared infra follows the same "one of it" rule everywhere it can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One OAuth callback per provider&lt;/strong&gt;, app-agnostic, at a single hub URL. Apps fan out from a &lt;code&gt;state&lt;/code&gt; param carrying &lt;code&gt;{ app, uid }&lt;/code&gt; instead of each registering its own redirect URI. Adding an app doesn't touch the Google Cloud console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Stripe webhook endpoint&lt;/strong&gt; for the whole suite, routing by &lt;code&gt;metadata.app&lt;/code&gt;. Stripe caps endpoints per account; sharing one means the cap never binds and there's a single signature to verify.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The AI layer is a wallet, not a hundred keys
&lt;/h2&gt;

&lt;p&gt;Every app has AI features, and AI features are where a solo operator gets destroyed two ways: unbounded cost, and copy-pasted prompt plumbing that rots. So the AI doesn't live in the apps either. It lives behind a credit-wallet API — the same Kynth Core I mentioned at the top.&lt;/p&gt;

&lt;p&gt;An app doesn't hold a model key or manage a rate limit. It spends credits against a wallet through a typed endpoint. The API is a suite of capabilities — document parsing (PDF / invoice / receipt / statement / contract → schema-valid JSON), text and structured-extraction endpoints, and more — each with a fixed request/response contract and a per-call credit cost. A runner gives every endpoint the same shape: auth, wallet debit, validation, execution, structured error. Adding an endpoint is implementing one function against that runner; every app gets it for free the moment it lands.&lt;/p&gt;

&lt;p&gt;This is why the AI is Pro-only inside the apps: a metered wallet has a real marginal cost, so the paywall isn't a growth tactic, it's the actual economics showing through. The free tier is the deterministic, non-AI path; the AI is what you pay for, in the app and on the API alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generation, not hand-authoring
&lt;/h2&gt;

&lt;p&gt;New apps aren't hand-built from an empty folder. There's a generation pipeline: a new app starts from the shared packages and the catalog entry, with the workspace shell, auth, billing, analytics, and SEO already wired because those come from imports, not from boilerplate I retype. What I actually write per app is the domain: the data model and the handful of screens that make &lt;em&gt;this&lt;/em&gt; app different from the last one. The horizontal 90% is inherited; I author the vertical 10%.&lt;/p&gt;

&lt;p&gt;That ratio is the whole story. It's why the studio can keep growing instead of stalling at a handful. The size isn't a flex — it's the proof I care about as a developer, because a whole studio of apps sharing one auth, one billing surface, one analytics contract, one deploy, and one AI wallet means those shared surfaces have been exercised across every failure mode the studio can throw at them. Battle-tested is a claim; a studio of apps in production is the receipt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you might want the engine
&lt;/h2&gt;

&lt;p&gt;Most of what I described you can't buy — it's a monorepo I run. But the part that's genuinely reusable, I extracted: the credit-metered capability layer is Kynth Core, and it's live at &lt;a href="https://api.kynth.studio" rel="noopener noreferrer"&gt;api.kynth.studio&lt;/a&gt; with SDKs on npm and PyPI and an MCP server. If you've ever bolted an AI feature onto an app and then spent the next month babysitting the key, the budget, and the retry logic, that's the exact tax this removes. You get a typed endpoint, a wallet with a hard ceiling, and a response you can trust the shape of.&lt;/p&gt;

&lt;p&gt;I built it because I needed it over and over, in every app I ship. That's the only endorsement I can give it, and it's the one I'd trust.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Isaiah Kim — one-person AI product studio.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>ai</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
