<?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: Renga</title>
    <description>The latest articles on DEV Community by Renga (@renga154).</description>
    <link>https://dev.to/renga154</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%2F4073404%2Faad84e8a-9902-418f-9a69-8f2490746d86.PNG</url>
      <title>DEV Community: Renga</title>
      <link>https://dev.to/renga154</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/renga154"/>
    <language>en</language>
    <item>
      <title>I published three posts in one day and two of them were never seen by anyone</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Sun, 30 Aug 2026 10:20:08 +0000</pubDate>
      <link>https://dev.to/renga154/i-published-three-posts-in-one-day-and-two-of-them-were-never-seen-by-anyone-402n</link>
      <guid>https://dev.to/renga154/i-published-three-posts-in-one-day-and-two-of-them-were-never-seen-by-anyone-402n</guid>
      <description>&lt;p&gt;I wrote eight technical posts and put them on dev.to. Then I pulled the view counts from the API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Published&lt;/th&gt;
&lt;th&gt;Posts that day&lt;/th&gt;
&lt;th&gt;Views per post&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aug 11&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Aug 14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 62 / 0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 15&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 16&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 21&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aug 22&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Every day I published once, the post got views. The one day I published three, two of them got zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not low. &lt;strong&gt;Zero. Never shown to anybody.&lt;/strong&gt; Two out of eight — a quarter of everything I had written — had never reached a single reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  I killed two explanations first
&lt;/h2&gt;

&lt;p&gt;My immediate thought was "one post per author per day." But &lt;strong&gt;believing the first plausible story is how you get this wrong&lt;/strong&gt;, so I went looking for anything else that fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Were the tags too small?
&lt;/h3&gt;

&lt;p&gt;No.&lt;/p&gt;

&lt;p&gt;The zero-view post was tagged &lt;code&gt;ffmpeg, python, video, tutorial&lt;/code&gt;. &lt;strong&gt;&lt;code&gt;python&lt;/code&gt; is the 8th most-followed tag on the platform and &lt;code&gt;tutorial&lt;/code&gt; is 7th.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Big tags and still zero. Tags don't explain it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Was it published at a bad hour?
&lt;/h3&gt;

&lt;p&gt;Also no — if anything, backwards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Views&lt;/th&gt;
&lt;th&gt;Published (US Eastern)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;00:27 — the middle of the night&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13:43 — prime time&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My best-performing post went out at the worst possible hour, and a zero-view post went out at a good one. The timing hypothesis doesn't survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only the count is left
&lt;/h2&gt;

&lt;p&gt;Not tags, not timing. The only thing that differs is &lt;strong&gt;how many posts I published that day.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forem's scoring, per the open implementation, blends roughly these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;match against tags the reader follows&lt;/li&gt;
&lt;li&gt;reactions the post has accumulated&lt;/li&gt;
&lt;li&gt;whether the reader already follows the author&lt;/li&gt;
&lt;li&gt;language match&lt;/li&gt;
&lt;li&gt;some random jitter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;With zero followers and zero reactions, tags are the only lever you have.&lt;/strong&gt; Cap per-author exposure on top of that, and a same-day second post has nothing left to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zenn does the same thing, worse
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Zenn has a posting-rate limit.&lt;/strong&gt; Hit it and the article simply doesn't publish.&lt;/p&gt;

&lt;p&gt;The nasty part is that &lt;strong&gt;nothing errors.&lt;/strong&gt; The git push succeeds. Only the deploy log mentions the limit, and the article URL sits at 404.&lt;/p&gt;

&lt;p&gt;I misdiagnosed this the first time as a frontmatter formatting problem, because &lt;strong&gt;the push had succeeded and I took that as evidence of publication.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a machine, not a resolution
&lt;/h2&gt;

&lt;p&gt;Deciding to post once a day isn't enough. &lt;strong&gt;You break it on the day you forget.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The publishing script now carries a 24-hour guard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ dev.to published within the last 24 hours (0h ago: "...").
  A second post the same day never reaches the feed and gets zero views.
  Wait another 23.7 hours.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zenn has the same guard, plus one more condition: &lt;strong&gt;it does not record the post as published until the URL returns 200.&lt;/strong&gt; The success of a push is not evidence that anything shipped.&lt;/p&gt;

&lt;p&gt;I tripped the guard myself within an hour of adding it. Without it, I would have produced another 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publish twice in a day and one of them is discarded.&lt;/strong&gt; Nothing errors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It isn't tags and it isn't timing.&lt;/strong&gt; Big tags at good hours still went to zero&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 200 from the API is not delivery.&lt;/strong&gt; Zenn returns a successful push and a 404 page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode it, don't resolve it.&lt;/strong&gt; A countermeasure that depends on remembering fails on the day you don't&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure after you ship.&lt;/strong&gt; Line up posts-per-day against views and this shape is visible immediately&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The writing time was not what bothered me. &lt;strong&gt;The time spent writing things that were thrown away&lt;/strong&gt; was.&lt;/p&gt;

</description>
      <category>devto</category>
      <category>writing</category>
      <category>analytics</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The jitter wasn't in the interpolation. It was in the schedule.</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Sat, 22 Aug 2026 01:23:52 +0000</pubDate>
      <link>https://dev.to/renga154/the-jitter-wasnt-in-the-interpolation-it-was-in-the-schedule-557p</link>
      <guid>https://dev.to/renga154/the-jitter-wasnt-in-the-interpolation-it-was-in-the-schedule-557p</guid>
      <description>&lt;p&gt;The avatar standing on my desktop looked like it was stuttering. One symptom — but it turned out to have &lt;strong&gt;three causes of completely different kinds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check them in the wrong order and you waste a lot of time. I started with interpolation, which was the wrong end.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. First, suspect overlap
&lt;/h2&gt;

&lt;p&gt;This was the big one. &lt;strong&gt;The next motion was starting before the previous one had finished.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The interval was set to 5 seconds. The motion was 9 seconds long. Calling it again mid-playback resets elapsed time to zero, so it snaps back to the first frame partway through. There's no chance to settle back into the idle pose before the next one begins. That reads as "stuttering".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The symptom is jitter, but the cause is the timetable, not the curve.&lt;/strong&gt; No amount of smoothing fixes it.&lt;/p&gt;

&lt;p&gt;Two things to check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the next run scheduled when the motion &lt;strong&gt;starts&lt;/strong&gt;, or when it &lt;strong&gt;finishes&lt;/strong&gt;?&lt;/li&gt;
&lt;li&gt;Does your "skip if already playing" guard cover &lt;strong&gt;every&lt;/strong&gt; playback path?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I got caught by the second. The guard only consulted a flag used by the &lt;code&gt;.vrma&lt;/code&gt; path, and the procedural-motion path walked straight past it. &lt;strong&gt;A gate on one of two doors is not a gate.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Then, suspect velocity discontinuity
&lt;/h2&gt;

&lt;p&gt;Eyes pick up discontinuity in &lt;strong&gt;velocity&lt;/strong&gt;, not position. Move at a constant rate and stop abruptly, and the stop itself looks like a jolt.&lt;/p&gt;

&lt;p&gt;Two common sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Linear trapezoids&lt;/strong&gt; (ramping with &lt;code&gt;t / rise&lt;/code&gt;) — velocity jumps at both the start and the peak&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Math.abs(Math.sin(x))&lt;/code&gt;&lt;/strong&gt; — forms a sharp point at every zero crossing. &lt;code&gt;(1 - cos) / 2&lt;/code&gt; is the drop-in replacement&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Detecting a kink without picking a threshold
&lt;/h3&gt;

&lt;p&gt;This is the practically useful part.&lt;/p&gt;

&lt;p&gt;My first attempt divided the jump by the amplitude and compared against a threshold. &lt;strong&gt;That doesn't work.&lt;/strong&gt; Larger motions are penalised, so legitimate movement trips the check.&lt;/p&gt;

&lt;p&gt;Instead, &lt;strong&gt;double the number of samples and see what happens to the jump.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;When you double the samples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A smooth curve&lt;/td&gt;
&lt;td&gt;the jump &lt;strong&gt;halves&lt;/strong&gt; — you were only sampling curvature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A real kink&lt;/td&gt;
&lt;td&gt;it &lt;strong&gt;doesn't change&lt;/strong&gt; — there is an actual corner there&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Independent of amplitude and of speed. And no threshold to guess at.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Last, check whether it's visible at all
&lt;/h2&gt;

&lt;p&gt;If you've fixed the smoothness and something still feels off, &lt;strong&gt;the motion may not be visible in the first place.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Movement along the depth axis — leaning forward, for instance — barely changes the image from a front-facing camera. I measured one pose where the top of the head moved &lt;strong&gt;10 px&lt;/strong&gt;. That's &lt;strong&gt;0.65%&lt;/strong&gt; of the screen height.&lt;/p&gt;

&lt;p&gt;"Something feels off" turned out to mean "effectively nothing is happening."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't measure in degrees. Measure how many pixels it moves on screen.&lt;/strong&gt; However far it rotates in 3D, if the camera can't see it move, it didn't move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the lesson down doesn't stop the repeat
&lt;/h2&gt;

&lt;p&gt;About that third one, a confession.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It was already written in a comment in the same repository.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Forward/back motion runs along the screen's depth axis, so from a front view it looks like it isn't doing anything&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I hit it once with a folded-arms pose and wrote that down at the time. &lt;strong&gt;Then I hit it again, somewhere else, having written it myself.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keeping the lesson in a note was not enough. &lt;strong&gt;Unless a machine fails the build, you repeat it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Smoothness now has &lt;code&gt;npm run check:smooth&lt;/code&gt;, implementing the sample-doubling test directly.&lt;/p&gt;

&lt;p&gt;Visibility I still measure by hand. That hole isn't closed yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Suspect overlap first.&lt;/strong&gt; Jitter doesn't imply the interpolation is at fault&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Skip if playing" must see every path.&lt;/strong&gt; A gate on one door is not a gate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect kinks by doubling the samples.&lt;/strong&gt; Dividing by amplitude penalises big motions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visibility is measured in pixels.&lt;/strong&gt; Degrees will lie to you&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn lessons into tooling.&lt;/strong&gt; You will hit your own notes twice&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Separately from this, I build a desktop AI agent called &lt;a href="https://rengaworks.gumroad.com/l/wisp?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=jitter-is-a-schedule-bug" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt;. It stands on your desktop, answers when you talk to it, and runs commands when you ask — always showing you what it's about to do first.&lt;/p&gt;

</description>
      <category>animation</category>
      <category>graphics</category>
      <category>ux</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My VRM was rejected at upload and every viewer I own said it was fine</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Fri, 21 Aug 2026 19:15:46 +0000</pubDate>
      <link>https://dev.to/renga154/my-vrm-was-rejected-at-upload-and-every-viewer-i-own-said-it-was-fine-34m7</link>
      <guid>https://dev.to/renga154/my-vrm-was-rejected-at-upload-and-every-viewer-i-own-said-it-was-fine-34m7</guid>
      <description>&lt;p&gt;I uploaded &lt;code&gt;Mira.vrm&lt;/code&gt; to VRoid Hub and it was rejected.&lt;/p&gt;

&lt;p&gt;Nothing was wrong on my machine. Every viewer opened it correctly. My own VRM validation passed.&lt;/p&gt;

&lt;p&gt;The error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POSITION Z: declared -0.391, actual -0.238
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The data was correct. The declaration about the data was wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  glTF accessors declare their own range
&lt;/h2&gt;

&lt;p&gt;In glTF, separately from the vertex data itself, each accessor declares &lt;code&gt;min&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt;.&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"VEC3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12043&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"min"&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="mf"&gt;-0.512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.391&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"max"&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="mf"&gt;0.512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.621&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mf"&gt;0.284&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;The spec requires that declaration to match the actual data. When it doesn't, the file is invalid — even though nothing about it looks wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you cannot catch this locally
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rendering reads the data.&lt;/strong&gt; &lt;code&gt;min&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt; are bounds metadata; they play no part in producing pixels. So when the declaration drifts, &lt;strong&gt;the render is still perfectly correct.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open it in a viewer — fine&lt;/li&gt;
&lt;li&gt;Run your own validator — passes&lt;/li&gt;
&lt;li&gt;Ship it to a device — works&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Everything you own reports "fine",&lt;/strong&gt; because this isn't the kind of information you can check by looking.&lt;/p&gt;

&lt;p&gt;The only thing that catches it is a distribution target that runs a real validator. Which means the place you find out is the moment before publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually broke
&lt;/h2&gt;

&lt;p&gt;A script that moves vertices around. On write-back it never recomputed &lt;code&gt;min&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Walking the chain:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Bounds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Input base file&lt;/td&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intermediate build product&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;drifted&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final output&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;drifted&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The input was clean, which pinned the damage to the transform step. Locating &lt;em&gt;where&lt;/em&gt; it broke is what tells you the thing to fix is the script, not the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it must not touch the vertices
&lt;/h2&gt;

&lt;p&gt;Recomputing bounds changes no vertex data at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node app/scripts/fix-accessor-bounds.mjs &amp;lt;file.vrm&amp;gt; &lt;span class="nt"&gt;--check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;--check&lt;/code&gt; it only inspects, exiting non-zero on drift. Without it, it repairs.&lt;/p&gt;

&lt;p&gt;Afterwards I took the sha256 of the BIN chunk and confirmed it was &lt;strong&gt;byte-identical to before the repair.&lt;/strong&gt; That's the guarantee that only the declaration moved. Skip that check and you're left unable to rule out "I broke the model while trying to fix it."&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the lesson down is not enough
&lt;/h2&gt;

&lt;p&gt;This is the real point.&lt;/p&gt;

&lt;p&gt;If "recompute bounds after moving vertices" only exists as a note, &lt;strong&gt;you will stop in the same place next time.&lt;/strong&gt; A countermeasure that depends on remembering fails on the day you don't remember.&lt;/p&gt;

&lt;p&gt;I wired it into &lt;code&gt;verify:release-assets&lt;/code&gt;. A broken model now means &lt;strong&gt;the build refuses to start.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I dropped in a deliberately broken model and confirmed it actually fails. &lt;strong&gt;Adding a check isn't finished until you've watched it reject something.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;This trap isn't specific to glTF.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata you cannot verify by looking must be recomputed on every transform, or checked by a machine.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bounds — min/max, bounding boxes&lt;/li&gt;
&lt;li&gt;Counts — count, length, total&lt;/li&gt;
&lt;li&gt;Hashes — checksums, ETags&lt;/li&gt;
&lt;li&gt;Offsets — byteOffset, index tables&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of them look correct while being wrong. And things that look correct go unquestioned right up until you publish.&lt;/p&gt;




&lt;p&gt;Separately from this, I build a desktop AI agent called &lt;a href="https://rengaworks.gumroad.com/l/wisp?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=accessor-bounds-drift" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt;. That &lt;code&gt;Mira.vrm&lt;/code&gt; is its default body. It stands on your desktop, answers when you talk to it, and runs commands when you ask — always showing you what it's about to do first.&lt;/p&gt;

</description>
      <category>gltf</category>
      <category>vrm</category>
      <category>threejs</category>
      <category>tooling</category>
    </item>
    <item>
      <title>I set the font to the largest size and found the same bug eleven times</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Sun, 16 Aug 2026 00:01:23 +0000</pubDate>
      <link>https://dev.to/renga154/i-set-the-font-to-the-largest-size-and-found-the-same-bug-eleven-times-496m</link>
      <guid>https://dev.to/renga154/i-set-the-font-to-the-largest-size-and-found-the-same-bug-eleven-times-496m</guid>
      <description>&lt;p&gt;I was cleaning up the UI on a side-project iOS app and did one thing: &lt;strong&gt;set Dynamic Type to XXXL and screenshot every screen.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reading the code had turned up nothing. The screenshots showed problems immediately.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Eleven of them, in the end. &lt;strong&gt;All the same cause.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the conclusion first. &lt;strong&gt;"A parent that pins things side by side" × "text that grows" is not a bug, it's a pattern.&lt;/strong&gt; And in Japanese it breaks reliably worse than in English.&lt;/p&gt;

&lt;h2&gt;
  
  
  English truncates. Japanese stacks vertically.
&lt;/h2&gt;

&lt;p&gt;Same layout, different failure depending on language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;English&lt;/strong&gt; wraps at word boundaries and, failing that, ends in &lt;code&gt;…&lt;/code&gt;. You can't read it, but you can tell something was cut.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Japanese doesn't do that.&lt;/strong&gt; It can break between almost any two characters, so once a column is squeezed to one character wide, you get &lt;strong&gt;one character per line, stacked downward.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Actual output:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rendered&lt;/th&gt;
&lt;th&gt;Intended&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Paire / d / Macs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Paired Macs (&lt;strong&gt;broken mid-word&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Claud / e / Code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One character per line&lt;/td&gt;
&lt;td&gt;"実行中のセッション" (Running sessions)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Three step labels stacked vertically&lt;/td&gt;
&lt;td&gt;A three-step horizontal stepper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;De / mo&lt;/code&gt;, turning the capsule into a circle&lt;/td&gt;
&lt;td&gt;A "Demo" badge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Paire / d / Macs&lt;/code&gt; is English breaking mid-word. Once the column has only a few characters left, even English gets there. &lt;strong&gt;Japanese gets there much earlier.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause had the same shape every time
&lt;/h2&gt;

&lt;p&gt;Nearly all eleven were this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;HStack&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;systemName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// fixed&lt;/span&gt;
  &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kt"&gt;Spacer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                 &lt;span class="c1"&gt;// pinned right&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The 44pt icon and the trailing value claim their width first, leaving the label column a few characters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix: rows that carry a value drop the value to the line below — but &lt;strong&gt;affordances like chevrons and toggles stay on the right.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That row component was shared across the whole settings tree, so &lt;strong&gt;fixing one place fixed the entire settings screen.&lt;/strong&gt; Which also means one decision inside a shared component was breaking eleven screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;ViewThatFits&lt;/code&gt; is not a general answer
&lt;/h2&gt;

&lt;p&gt;I used &lt;code&gt;ViewThatFits&lt;/code&gt; to switch to a stacked layout. &lt;strong&gt;It broke the standard size.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ViewThatFits&lt;/code&gt; decides using each child's &lt;strong&gt;ideal width&lt;/strong&gt;. A title that wraps reports &lt;strong&gt;its unwrapped full length&lt;/strong&gt; as its ideal width. So the side-by-side candidate was judged "doesn't fit" and the badge dropped below the title &lt;em&gt;at the standard size.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could not have reasoned my way to this. I found it by screenshotting the standard size.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Where each one belongs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ViewThatFits&lt;/code&gt;&lt;/strong&gt; — when every child is short and has an intrinsic width (badges, two-word metrics)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An explicit &lt;code&gt;isAccessibilitySize&lt;/code&gt; branch&lt;/strong&gt; — when one of the children is a sentence that wraps&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Only pin things to the right if you operate them in place
&lt;/h2&gt;

&lt;p&gt;I reversed one of my own calls partway through.&lt;/p&gt;

&lt;p&gt;I'd classified a &lt;code&gt;.menu&lt;/code&gt; Picker with Toggle, as something that belongs pinned right. &lt;strong&gt;A screenshot of the audit-log screen disproved that&lt;/strong&gt; — the filter label came out as &lt;code&gt;絞り… / 込み&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule I ended up with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin right&lt;/strong&gt; — controls whose state changes in place (Toggle)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowed to take the full-width row&lt;/strong&gt; — things that merely open something (&lt;code&gt;.menu&lt;/code&gt; Picker, chevron)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The modals had never been tested at all
&lt;/h2&gt;

&lt;p&gt;I had a &lt;code&gt;UITEST_FORCE_ACCESSIBILITY_XXXL&lt;/code&gt; env var for UI tests. It applies &lt;code&gt;.dynamicTypeSize&lt;/code&gt; to the root view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That does not propagate into a &lt;code&gt;.sheet&lt;/code&gt;'s environment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the paywall and the setup guide had &lt;strong&gt;never once been rendered at the largest text size.&lt;/strong&gt; What was passing was "the tests are green," not "the modals were checked."&lt;/p&gt;

&lt;p&gt;Now it's applied from the system side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xcrun simctl ui &amp;lt;udid&amp;gt; content_size accessibility-extra-extra-extra-large
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  I declared convergence twice
&lt;/h2&gt;

&lt;p&gt;After fixing six, I wrote that the pattern had converged. &lt;strong&gt;It hadn't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There were simply screens I hadn't looked at. When I did, four more appeared — numbers seven through ten. Then I went through the QR scanner, host-add and pairing flows in Japanese at XXXL, confirmed &lt;strong&gt;zero new findings&lt;/strong&gt;, and only then called it converged at eleven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson is: don't declare convergence until you've actually looked at the screens.&lt;/strong&gt; Judge by how many screens you looked at, not by how many you fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  And, incidentally, zero pluralization
&lt;/h2&gt;

&lt;p&gt;While reading the audit log, &lt;code&gt;1 items need attention&lt;/code&gt; caught my eye. I swept the strings: of &lt;strong&gt;38 English strings with a quantity placeholder, zero had plural variants.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;1 items need attention&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 saved Macs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1 days free&lt;/code&gt; (on the paywall)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;1 sessions, 1 active…&lt;/code&gt; — &lt;strong&gt;in a share-sheet body, i.e. it leaves the device&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thirteen keys got variants. Japanese has no plural agreement, so nothing changed there. Strings like &lt;code&gt;%d of %d Macs&lt;/code&gt;, where the number doesn't agree with the noun, were deliberately left alone.&lt;/p&gt;

&lt;p&gt;The verification order mattered. Add one key, build, confirm &lt;code&gt;1 item needs attention&lt;/code&gt; on a real screen, &lt;em&gt;then&lt;/em&gt; roll out the rest. Finally, &lt;strong&gt;read &lt;code&gt;en.lproj/Localizable.stringsdict&lt;/code&gt; out of the build product&lt;/strong&gt; and confirm &lt;code&gt;NSStringPluralRuleType&lt;/code&gt; was generated correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I deliberately didn't change
&lt;/h2&gt;

&lt;p&gt;Recorded honestly, on the principle of not manufacturing work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The list screens survive XXXL intact&lt;/li&gt;
&lt;li&gt;Onboarding is fully reachable by scrolling at XXXL (clipping at the boundary is normal &lt;code&gt;ScrollView&lt;/code&gt; behaviour)&lt;/li&gt;
&lt;li&gt;The top padding in settings is the standard &lt;code&gt;.large&lt;/code&gt; navigation title layout — the same as Apple's own apps&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Fixed horizontal layout" × "growing text" is a pattern, not a bug.&lt;/strong&gt; Find one, suspect every screen&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese breaks worse than English.&lt;/strong&gt; English truncates; Japanese wraps anywhere, so it stacks vertically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One decision in a shared component broke eleven screens&lt;/strong&gt; — and fixing one place fixed all of them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ViewThatFits&lt;/code&gt; judges on ideal width.&lt;/strong&gt; A wrapping sentence among its children will break your standard size&lt;/li&gt;
&lt;li&gt;Pin to the right only what the user &lt;strong&gt;operates in place&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Env vars don't reach sheets.&lt;/strong&gt; "Tests are green" is not "I looked at the modal"&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't declare convergence until you've looked at the screens&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading the code found zero of the eleven.&lt;/p&gt;




&lt;p&gt;Separately from this, I build a desktop AI agent called &lt;a href="https://rengaworks.gumroad.com/l/wisp?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=xxxl-broke-japanese-worse" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt;.&lt;br&gt;
It stands on your desktop, answers when you talk to it, and runs commands when you ask — always showing you what it's about to do first.&lt;/p&gt;

</description>
      <category>swiftui</category>
      <category>ios</category>
      <category>a11y</category>
      <category>i18n</category>
    </item>
    <item>
      <title>My evidence pipeline was saving Cloudflare block pages as evidence</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Sat, 15 Aug 2026 15:58:57 +0000</pubDate>
      <link>https://dev.to/renga154/my-evidence-pipeline-was-saving-cloudflare-block-pages-as-evidence-25ml</link>
      <guid>https://dev.to/renga154/my-evidence-pipeline-was-saving-cloudflare-block-pages-as-evidence-25ml</guid>
      <description>&lt;p&gt;I build a web service that preserves evidence of harassment on social platforms.&lt;/p&gt;

&lt;p&gt;The core feature is a single thing: &lt;strong&gt;automatically capture a real screenshot of the offending post.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There was no substitute for it. I built an alternative that pulled the text through an API and rendered a tidy "evidence card" image, and threw it away. &lt;strong&gt;An image you can author freely afterwards proves nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the conclusion first. &lt;strong&gt;Third-party wrappers eventually die, and when they do, the failure comes back as a plausible-looking image rather than an error.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first approach was refused by the other side
&lt;/h2&gt;

&lt;p&gt;I started with Cloudflare Browser Rendering. The wiring worked. The capture didn't.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;X blocks headless browsers.&lt;/strong&gt; The request times out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube refuses script injection under a Trusted Types CSP.&lt;/strong&gt; There's no way to make it render the comment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is a bug in my implementation — &lt;strong&gt;that is how they are built.&lt;/strong&gt; So I declared Cloudflare alone impossible for this and moved to a service with a real browser and bot avoidance behind it.&lt;/p&gt;

&lt;p&gt;Both captures started working. For X, open the post page and clip the tweet element. For YouTube, open the URL with &lt;code&gt;&amp;amp;lc=&lt;/code&gt; and screenshot just that comment element.&lt;/p&gt;

&lt;p&gt;Element screenshots have one trap worth knowing: &lt;strong&gt;&lt;code&gt;selector_algorithm=clip&lt;/code&gt; returns a blank image when the element sits below the fold.&lt;/strong&gt; The selector matches, the capture "succeeds," and the file is empty. That took a while to see.&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="nt"&gt;ytd-comment-thread-renderer&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;href&lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="s1"&gt;"lc=ID"&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A parameter that had worked started returning 400
&lt;/h2&gt;

&lt;p&gt;I wanted timestamps rendered in Japan time, so I passed &lt;code&gt;time_zone: Asia/Tokyo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One day every request started coming back 400.&lt;/strong&gt; Every capture failed.&lt;/p&gt;

&lt;p&gt;The provider had narrowed which timezones they accept. Nothing changed on my side.&lt;/p&gt;

&lt;p&gt;I could diagnose it immediately only because &lt;strong&gt;I was storing the raw error body in the database.&lt;/strong&gt; The response went into &lt;code&gt;rawPayload.screenshotError&lt;/code&gt;, so opening one row told me why. Without that, this starts as "captures stopped working, no idea why."&lt;/p&gt;

&lt;p&gt;The fix was to drop the parameter, leave the rendering in GMT, and &lt;strong&gt;record the legally meaningful capture timestamp on my side, in JST.&lt;/strong&gt; How a third party displays a time is their business and can change. What I record is mine and doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  This was the bad one
&lt;/h2&gt;

&lt;p&gt;For X captures I was going through a third-party embed wrapper called &lt;code&gt;twitframe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That domain had been retired.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is what that looks like from the inside. The capture succeeds. HTTP 200. An image is written to storage. &lt;strong&gt;The image is a Cloudflare block page.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So: as evidence of online harassment, I was &lt;strong&gt;storing error screens.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If it had failed, I'd have known. It didn't. A stored, plausible-looking image doesn't stand out when you scan a list of them. You find out when someone needs to actually use it.&lt;/p&gt;

&lt;p&gt;Two causes stacked:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A dependency on a third-party wrapper.&lt;/strong&gt; When the domain dies, nobody tells you&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ignore_host_errors: true&lt;/code&gt; was set.&lt;/strong&gt; That says "continue capturing even if the host returns an error" — which is precisely permission to &lt;strong&gt;save a block page as a success&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;ignore_host_errors&lt;/code&gt; is reasonable when a slightly broken capture beats no capture. &lt;strong&gt;In a feature that handles evidence, it is only a hazard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;X capture now goes through the official embed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;platform.twitter.com/embed/Tweet.html?id=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A third-party wrapper ends when that person stops caring. An official embed is maintained because the platform wants it maintained. &lt;strong&gt;Both are "someone else's," but only one has a reason to keep existing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it faster removed the evidence
&lt;/h2&gt;

&lt;p&gt;Scanning was slow, so I moved capture from synchronous to background. It got faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saved evidence stopped having screenshots attached.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The save path assumed "by the time we get here, the screenshot exists" and copied it forward. Make the earlier stage async and that assumption quietly stops holding.&lt;/p&gt;

&lt;p&gt;The fix is two-layered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scanning responds immediately and captures in the background&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;at the moment evidence is saved, check for the screenshot and capture it right there if missing&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anything missed also gets picked up by the next scan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you move a synchronous step to the background, audit every later stage that inherited data assuming it was synchronous.&lt;/strong&gt; Optimise on speed alone and this is the shape of the breakage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Third-party wrappers die.&lt;/strong&gt; Use the official embed&lt;/li&gt;
&lt;li&gt;When they die, &lt;strong&gt;the failure arrives as a plausible image, not an exception&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Flags like &lt;code&gt;ignore_host_errors&lt;/code&gt; that mean "continue even if it looks wrong" are &lt;strong&gt;pure hazard when the artifact is evidence&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider parameters change.&lt;/strong&gt; Keeping the raw error body makes the day it changes a five-minute diagnosis&lt;/li&gt;
&lt;li&gt;Separate values that depend on someone else's rendering (timezone) from &lt;strong&gt;values you must record yourself&lt;/strong&gt; (capture time)&lt;/li&gt;
&lt;li&gt;Move something to async, then &lt;strong&gt;re-check what the later stages assumed&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"The capture succeeded" and "we captured evidence" turned out to be different claims. The first is visible in the status code. The second requires looking at the pixels.&lt;/p&gt;




&lt;p&gt;Separately from this, I build a desktop AI agent called &lt;a href="https://rengaworks.gumroad.com/l/wisp?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=block-pages-as-evidence" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt;.&lt;br&gt;
It stands on your desktop, answers when you talk to it, and runs commands when you ask — always showing you what it's about to do first.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>playwright</category>
      <category>webscraping</category>
      <category>architecture</category>
    </item>
    <item>
      <title>My dev environment was hiding eight production bugs</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Fri, 14 Aug 2026 17:43:04 +0000</pubDate>
      <link>https://dev.to/renga154/my-dev-environment-was-hiding-eight-production-bugs-50jg</link>
      <guid>https://dev.to/renga154/my-dev-environment-was-hiding-eight-production-bugs-50jg</guid>
      <description>&lt;p&gt;I pushed a side-project iOS app to TestFlight and then stopped.&lt;br&gt;
&lt;strong&gt;Every test I had run up to that point had run under &lt;code&gt;__DEV__&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sixty-second unlocks, anonymous sign-in, sample recordings.&lt;br&gt;
Which means &lt;strong&gt;the paths that only exist in production had never executed once.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I don't own a spare device, so the most useful thing I could do was read those&lt;br&gt;
paths in code. That turned up &lt;strong&gt;eight defects, every one of them production-only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is the conclusion first. &lt;strong&gt;Enumerate the places where your development&lt;br&gt;
environment is being convenient, and the defects are sitting right next to them.&lt;/strong&gt;&lt;br&gt;
Counting conveniences is faster than hunting bugs.&lt;/p&gt;
&lt;h2&gt;
  
  
  A 60-second unlock hid a duplicate notification
&lt;/h2&gt;

&lt;p&gt;Notifications in this app arrive seven days later. Two things scheduled them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creation time registered a task&lt;/li&gt;
&lt;li&gt;a 03:00 daily sweep picked up anything not yet registered&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The creation-time path never wrote the flag saying it had registered&lt;br&gt;
(&lt;code&gt;notificationScheduled&lt;/code&gt;). A seven-day capsule &lt;strong&gt;matches the sweep's condition&lt;br&gt;
exactly&lt;/strong&gt;, so both registered it and two notifications fired at the same moment.&lt;/p&gt;

&lt;p&gt;In development I opened everything after 60 seconds. Opening it takes it out of&lt;br&gt;
the condition, so &lt;strong&gt;the 03:00 sweep never once saw it.&lt;/strong&gt; That's why I never saw it.&lt;/p&gt;

&lt;p&gt;Now the creation path writes the flag and claims ownership.&lt;/p&gt;
&lt;h2&gt;
  
  
  The simulator not receiving notifications hid a missing main path
&lt;/h2&gt;

&lt;p&gt;The tap subscription only used &lt;code&gt;onNotificationOpenedApp&lt;/code&gt;, which &lt;strong&gt;fires only&lt;br&gt;
while the app is alive in the background.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The notification arrives seven days later. By then the app is almost certainly&lt;br&gt;
terminated. &lt;strong&gt;The main path was missing entirely.&lt;/strong&gt;&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;export&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;consumeLaunchNotification&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;return&lt;/span&gt; &lt;span class="nf"&gt;capsuleIdOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getInitialNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messaging&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 same blind spot hid a second defect: nothing registered the device token&lt;br&gt;
right after the user granted permission.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On launch, try to register — fails, no permission (swallowed by &lt;code&gt;.catch(() =&amp;gt; {})&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;User records something, gets asked for permission, grants it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nobody registers here&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Until the next launch, the server does not know this device exists&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The very first capsule unlocks after 24 hours. &lt;strong&gt;That lands squarely inside the&lt;br&gt;
window above.&lt;/strong&gt; The first notification is the single most important one a product&lt;br&gt;
sends, and it was likely never arriving.&lt;/p&gt;
&lt;h2&gt;
  
  
  Treating every send failure as a dead token
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deadTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Plenty of FCM failures are transient: &lt;code&gt;internal-error&lt;/code&gt;, &lt;code&gt;server-unavailable&lt;/code&gt;,&lt;br&gt;
dropped connections, quota. &lt;strong&gt;Valid tokens were being deleted by all of them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Re-registration only happens on next launch. In an app people open once a week,&lt;br&gt;
once you enter "no notifications, so I don't open it," &lt;strong&gt;you never come back.&lt;/strong&gt;&lt;br&gt;
The loop ends quietly.&lt;/p&gt;

&lt;p&gt;Now only genuinely dead codes delete.&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;DEAD_TOKEN_CODES&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;messaging/registration-token-not-registered&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;messaging/invalid-registration-token&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;messaging/invalid-argument&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Anonymous sign-in hid a reset that destroys the rhythm
&lt;/h2&gt;

&lt;p&gt;The user-document upsert wrote &lt;code&gt;hasCompletedFirstCapsule: false&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;unconditionally, every time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sign out and back in — or reinstall — and &lt;strong&gt;the next capsule opens in 24 hours&lt;br&gt;
instead of 7 days.&lt;/strong&gt; The weekly rhythm, which is the whole product, breaks.&lt;/p&gt;

&lt;p&gt;In development, anonymous sign-in hands you a new uid each time, so you never hit&lt;br&gt;
an existing document. &lt;strong&gt;That's why I never hit it.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  An always-on connection hid "I recorded it and it vanished"
&lt;/h2&gt;

&lt;p&gt;A function returning the pending-upload count existed. &lt;strong&gt;Nothing called it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Record somewhere with no signal and this happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the upload fails, so no document lands in Firestore&lt;/li&gt;
&lt;li&gt;the home screen reads that collection, so it shows &lt;strong&gt;"nothing recorded yet"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;to the user it looks exactly like &lt;strong&gt;the recording vanished&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testers use this on trains. &lt;strong&gt;That is not an edge case, it's the normal path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The copy I settled on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;3 items haven't been sent yet.&lt;br&gt;
They'll go automatically when you have signal. They are not lost.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"They are not lost" is stated explicitly because in that moment it is&lt;br&gt;
&lt;strong&gt;the only thing the user actually wants to know.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Permissions being pre-granted hid a dead end
&lt;/h2&gt;

&lt;p&gt;iOS &lt;strong&gt;shows the system permission dialog exactly once.&lt;/strong&gt; After someone taps&lt;br&gt;
"Don't Allow," requesting again shows nothing and returns &lt;code&gt;granted: false&lt;/code&gt;&lt;br&gt;
immediately.&lt;/p&gt;

&lt;p&gt;The screen still offered an "Allow" button. &lt;strong&gt;Pressing it did nothing.&lt;/strong&gt;&lt;br&gt;
The answer screen deliberately has no way out, so &lt;strong&gt;anyone who denied the&lt;br&gt;
microphone could not answer at all.&lt;/strong&gt; That is the response rate, dead.&lt;/p&gt;

&lt;p&gt;Now it checks &lt;code&gt;canAskAgain&lt;/code&gt; and switches to "Open Settings" when asking is over.&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;exhausted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cameraPermission&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;cameraPermission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canAskAgain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;microphonePermission&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;microphonePermission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canAskAgain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The simulator grants permissions, so this branch never ran.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not having shipped the public feed hid two rule holes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ownership check reads the uid on the &lt;strong&gt;existing&lt;/strong&gt; document. Rewrite the uid&lt;br&gt;
in the request to someone else's and the condition still passes, because the old&lt;br&gt;
uid is yours.&lt;/p&gt;

&lt;p&gt;Separately, the read condition was&lt;br&gt;
&lt;code&gt;visibility == 'public' &amp;amp;&amp;amp; moderationStatus == 'ok'&lt;/code&gt; while updates were&lt;br&gt;
unrestricted — so an owner can &lt;strong&gt;set both in the same write.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no public feed UI yet, so nothing surfaces.&lt;br&gt;
&lt;strong&gt;The moment the public feature ships, review becomes self-approval.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moderation only means anything when the moderated party cannot write the verdict.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;
              &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moderationStatus&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moderationStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Some I left alone
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;createdAt&lt;/code&gt; not pinned&lt;/td&gt;
&lt;td&gt;You can fake "N days ago," but only to yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;status&lt;/code&gt; freely writable&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Owner can read own media pre-unlock&lt;/td&gt;
&lt;td&gt;Documented as known in the rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each of these only lets you &lt;strong&gt;break your own experience&lt;/strong&gt;. Re-evaluating when the&lt;br&gt;
public feature ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take away
&lt;/h2&gt;

&lt;p&gt;All eight were concealed by a property of the development environment itself.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What hid it&lt;/th&gt;
&lt;th&gt;The defect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;60-second unlock&lt;/td&gt;
&lt;td&gt;Duplicate notification (never reached the 03:00 sweep)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simulator gets no notifications&lt;/td&gt;
&lt;td&gt;Tap from terminated state; token unregistered after grant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anonymous sign-in (fresh uid each time)&lt;/td&gt;
&lt;td&gt;Re-sign-in resets to first-run state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable always-on connection&lt;/td&gt;
&lt;td&gt;Upload failure invisible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permissions pre-granted&lt;/td&gt;
&lt;td&gt;Anyone who denied once can never record again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No public feed yet&lt;/td&gt;
&lt;td&gt;uid and moderation verdict both rewritable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;"It worked in dev" is not evidence that it works in production&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Don't hunt bugs — &lt;strong&gt;enumerate where your environment is being convenient&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;List the conveniences one line at a time; the defects are next to them&lt;/li&gt;
&lt;li&gt;Treating transient and permanent failures identically &lt;strong&gt;deletes the recovery path&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put failures on screen.&lt;/strong&gt; Anything not shown reads to a user as "gone"&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The app this came out of is &lt;a href="https://rengaworks.gumroad.com/l/wisp?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dev-env-hid-eight-bugs" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt;.&lt;br&gt;
It stands on your desktop, answers when you talk to it, and runs commands when you ask.&lt;br&gt;
It always shows you what it is about to do, and waits for your approval.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>firebase</category>
      <category>ios</category>
      <category>testing</category>
    </item>
    <item>
      <title>My approval dialog grew until the buttons went off screen</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Fri, 14 Aug 2026 04:27:06 +0000</pubDate>
      <link>https://dev.to/renga154/my-approval-dialog-grew-until-the-buttons-went-off-screen-247o</link>
      <guid>https://dev.to/renga154/my-approval-dialog-grew-until-the-buttons-went-off-screen-247o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://wisp-gules-mu.vercel.app/blog/approval-dialog/" rel="noopener noreferrer"&gt;my own site&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I build a desktop app where an AI agent can run commands on your machine. Every&lt;br&gt;
command goes through an approval dialog first: here is exactly what I am about&lt;br&gt;
to run, yes or no.&lt;/p&gt;

&lt;p&gt;Halfway through recording a demo, I got stuck. &lt;strong&gt;The buttons went off the&lt;br&gt;
bottom of the screen and I could neither approve nor reject.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The conclusion first: &lt;strong&gt;showing the user everything and letting the user choose&lt;br&gt;
are two different properties.&lt;/strong&gt; Once they cannot choose, there is no approval&lt;br&gt;
step — there is just a modal.&lt;/p&gt;
&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;The model decided to write an HTML file, and built this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; index.html &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
&amp;lt;!doctype html&amp;gt;
… 4,558 bytes of HTML …
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That whole string goes into &lt;code&gt;showMessageBox&lt;/code&gt;'s &lt;code&gt;detail&lt;/code&gt;. &lt;strong&gt;The file contents&lt;br&gt;
became the height of the approval dialog.&lt;/strong&gt; It grew taller than the display and&lt;br&gt;
the buttons ended up somewhere below the bottom edge.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;detail&lt;/code&gt; does not scroll.&lt;/p&gt;
&lt;h2&gt;
  
  
  Escape worked. Enter did not.
&lt;/h2&gt;

&lt;p&gt;Two things I found while stuck:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Escape dismissed it&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enter did nothing&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because I had set &lt;code&gt;defaultId&lt;/code&gt; and &lt;code&gt;cancelId&lt;/code&gt; both to 0 — the safe button. On&lt;br&gt;
macOS, when those two point at the same index, &lt;strong&gt;the Return key equivalent is&lt;br&gt;
dropped.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Defaulting to the safe button is the right call; it stops a stray Enter from&lt;br&gt;
approving something dangerous. But &lt;strong&gt;that same setting removes the keyboard&lt;br&gt;
path to approving at all.&lt;/strong&gt; Decide one without looking at the other and this is&lt;br&gt;
where you land.&lt;/p&gt;

&lt;p&gt;Having Escape as the only escape hatch is a thin design.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same recording broke in a second place
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cd ~/work &amp;amp;&amp;amp; claude "build me a landing page"&lt;/code&gt; went down the&lt;br&gt;
wait-for-the-result path. Claude Code sat at its prompt until the 60-second&lt;br&gt;
timeout killed it.&lt;/p&gt;

&lt;p&gt;No terminal ever opened, so &lt;strong&gt;from the user's side nothing happened at all.&lt;/strong&gt;&lt;br&gt;
I confirmed it afterwards: not one hand-off script had been written to disk.&lt;/p&gt;

&lt;p&gt;Interactive commands must never go down a path that waits for output.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three layers of fix
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Split file writing out of command execution
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What the approver needs to see is different for the two operations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a command, you need the whole thing to judge what it does. For a write, what&lt;br&gt;
you need is &lt;strong&gt;where, how many bytes, and is it an overwrite.&lt;/strong&gt; The content&lt;br&gt;
itself can be inspected separately if you want it.&lt;/p&gt;

&lt;p&gt;Splitting the tool &lt;strong&gt;decoupled the dialog's size from the content's size.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;content written&lt;/th&gt;
&lt;th&gt;dialog&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4,558 bytes of HTML&lt;/td&gt;
&lt;td&gt;16 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;34,889 bytes&lt;/td&gt;
&lt;td&gt;17 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three more decisions came with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overwrites are their own case.&lt;/strong&gt; &lt;code&gt;warning&lt;/code&gt; type, and the words "this will be lost"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expand &lt;code&gt;~&lt;/code&gt; and relative paths before asking.&lt;/strong&gt; Approve the unexpanded string
and the path you approved can differ from the path that gets written&lt;/li&gt;
&lt;li&gt;Both dialogs default to the "do not write" button&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  2. Refuse terminal-hogging commands &lt;em&gt;before&lt;/em&gt; asking
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;claude&lt;/code&gt;, &lt;code&gt;codex&lt;/code&gt;, &lt;code&gt;gemini&lt;/code&gt;, &lt;code&gt;vim&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;psql&lt;/code&gt; and 18 others. Check &lt;strong&gt;every&lt;br&gt;
segment&lt;/strong&gt; joined by &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;.&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findInteractiveCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;command&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="kr"&gt;string&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;const&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;command&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;&amp;amp;|&lt;/span&gt;&lt;span class="se"&gt;\|\|&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;;|&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&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;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&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="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z_&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*=/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// skip FOO=1&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^.*&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INTERACTIVE_COMMANDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&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;head&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Making someone click a button and then failing is bad manners.&lt;/strong&gt; Refuse&lt;br&gt;
before the dialog, and tell the model to use the hand-it-to-a-terminal tool&lt;br&gt;
instead.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. If it is still long, truncate the &lt;em&gt;display&lt;/em&gt;
&lt;/h3&gt;

&lt;p&gt;14 lines / 900 characters. Write the full text to a temp file and &lt;strong&gt;put that&lt;br&gt;
path in the dialog.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never truncate the thing being approved.&lt;/strong&gt; Only the string being shown. Mix&lt;br&gt;
those two up and you get the worst possible shape: the user approves something&lt;br&gt;
short and something long runs.&lt;/p&gt;
&lt;h2&gt;
  
  
  How do you test "can still be clicked"?
&lt;/h2&gt;

&lt;p&gt;You cannot eyeball it. What you can write down is &lt;strong&gt;that the size does not&lt;br&gt;
depend on the content.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stays the same size regardless of content length&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="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;short&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;planWrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/t/a.html&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;&amp;lt;p&amp;gt;hi&amp;lt;/p&amp;gt;&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;/&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&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;huge&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;planWrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/t/a.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;Array&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="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&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="s2"&gt;`&amp;lt;p&amp;gt;&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="s2"&gt;&amp;lt;/p&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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;/&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildWriteApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;short&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reason&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;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;options&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildWriteApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;huge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reason&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;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;options&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&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;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&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="nf"&gt;toBeLessThan&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;a.length + 200&lt;/code&gt; is the real assertion; &lt;code&gt;900&lt;/code&gt; is a belt-and-braces ceiling.&lt;br&gt;
&lt;strong&gt;"The big one is about the same as the small one"&lt;/strong&gt; encodes the entire reason&lt;br&gt;
the tool was split.&lt;/p&gt;

&lt;p&gt;For the interactive check, assert that &lt;strong&gt;no dialog was shown at all&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refuses before asking — no dialog at all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;showMessageBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fn&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;executeCommand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;runCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cd ~/x &amp;amp;&amp;amp; claude "build it"&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;reason&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;showMessageBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;executeCommand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;…&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;showMessageBox&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;executeCommand&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalled&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;h2&gt;
  
  
  A postscript: the display depended on how the argument was passed
&lt;/h2&gt;

&lt;p&gt;While fixing the above I found one more. The "open a file" approval was&lt;br&gt;
&lt;strong&gt;printing whatever string the model handed it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;First call: &lt;code&gt;~/Downloads/…&lt;/code&gt;. Second call: &lt;code&gt;/Users/&amp;lt;my-username&amp;gt;/Downloads/…&lt;/code&gt;.&lt;br&gt;
Same file, different display.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the display depends on how the argument was phrased, it is not an approval&lt;br&gt;
screen.&lt;/strong&gt; Now the path is resolved to an absolute path for opening, and only&lt;br&gt;
the displayed string is folded back to &lt;code&gt;~&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Worse, the &lt;code&gt;~&lt;/code&gt; form &lt;strong&gt;would not have opened at all&lt;/strong&gt; — Electron's&lt;br&gt;
&lt;code&gt;shell.openPath&lt;/code&gt; does not expand it. Approving that dialog did nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Showing and choosing are different.&lt;/strong&gt; No choice, no approval&lt;/li&gt;
&lt;li&gt;Matching &lt;code&gt;defaultId&lt;/code&gt; and &lt;code&gt;cancelId&lt;/code&gt; kills Enter on macOS&lt;/li&gt;
&lt;li&gt;What the approver needs differs per operation. &lt;strong&gt;Split the tool&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If you are going to refuse, refuse &lt;strong&gt;before&lt;/strong&gt; the click&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never truncate what is being approved.&lt;/strong&gt; Only what is displayed&lt;/li&gt;
&lt;li&gt;"Still clickable" is testable as &lt;strong&gt;size does not depend on content&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The app this came out of is &lt;a href="https://rengaworks.gumroad.com/l/wisp" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt; — a&lt;br&gt;
desktop agent with a 3D body that runs commands and writes files, and shows you&lt;br&gt;
exactly what it is about to do first.&lt;/p&gt;

</description>
      <category>electron</category>
      <category>typescript</category>
      <category>ux</category>
      <category>ai</category>
    </item>
    <item>
      <title>I redacted my username from a screen recording three times and missed it every time</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Fri, 14 Aug 2026 03:46:41 +0000</pubDate>
      <link>https://dev.to/renga154/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every-time-1gnd</link>
      <guid>https://dev.to/renga154/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every-time-1gnd</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://wisp-gules-mu.vercel.app/blog/mask-screen-recording/" rel="noopener noreferrer"&gt;my own site&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I make a desktop AI agent. I recorded a demo of it writing files, and the&lt;br&gt;
approval dialog showed &lt;code&gt;/Users/&amp;lt;my-username&amp;gt;/Downloads/…&lt;/code&gt; in plain text.&lt;/p&gt;

&lt;p&gt;Blurring it out should have been a five-minute job. &lt;strong&gt;I checked the result by&lt;br&gt;
eye three times and missed a leak every time.&lt;/strong&gt; The fix that finally worked was&lt;br&gt;
scanning the finished video with a machine.&lt;/p&gt;

&lt;p&gt;The conclusion first: &lt;strong&gt;you cannot verify a redaction by looking at it.&lt;/strong&gt; The&lt;br&gt;
scan needs to be as much a part of the pipeline as the blur.&lt;/p&gt;
&lt;h2&gt;
  
  
  A fixed rectangle does not cover a moving dialog
&lt;/h2&gt;

&lt;p&gt;The naive version: open one frame where the path is visible, measure the box,&lt;br&gt;
and paint over it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;delogo&lt;/span&gt;=&lt;span class="n"&gt;x&lt;/span&gt;=&lt;span class="m"&gt;450&lt;/span&gt;:&lt;span class="n"&gt;y&lt;/span&gt;=&lt;span class="m"&gt;300&lt;/span&gt;:&lt;span class="n"&gt;w&lt;/span&gt;=&lt;span class="m"&gt;370&lt;/span&gt;:&lt;span class="n"&gt;h&lt;/span&gt;=&lt;span class="m"&gt;44&lt;/span&gt;:&lt;span class="n"&gt;enable&lt;/span&gt;=&lt;span class="s1"&gt;'between(t,99.9,102.4)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result had the username visible &lt;strong&gt;for the first one or two seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;macOS dialogs animate in. They keep moving until they settle.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;time (s)&lt;/th&gt;
&lt;th&gt;dialog top&lt;/th&gt;
&lt;th&gt;dialog left&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;98.4&lt;/td&gt;
&lt;td&gt;372&lt;/td&gt;
&lt;td&gt;458&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;98.8&lt;/td&gt;
&lt;td&gt;230&lt;/td&gt;
&lt;td&gt;180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.2&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;462&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99.6&lt;/td&gt;
&lt;td&gt;71&lt;/td&gt;
&lt;td&gt;407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100.0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;398&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;102.4&lt;/td&gt;
&lt;td&gt;94&lt;/td&gt;
&lt;td&gt;398&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A box measured at 100.0 s lands nowhere near the frame at 98.4 s — and the text&lt;br&gt;
is legible the whole way in.&lt;/p&gt;

&lt;p&gt;You can sidestep this by trimming each cut to start after the dialog settles.&lt;br&gt;
That was not available here: the whole point of the video was that it is&lt;br&gt;
uncut. &lt;strong&gt;So the box has to follow the text.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Track it with an FFT correlation
&lt;/h2&gt;

&lt;p&gt;The dialog moves, but the font size does not. So take the rendered&lt;br&gt;
&lt;code&gt;/Users/sa&lt;/code&gt; from a settled frame as a &lt;strong&gt;stencil&lt;/strong&gt;, and slide it over every&lt;br&gt;
frame to find the best match.&lt;/p&gt;

&lt;p&gt;Done directly, that is 2 million positions × 5,700 pixels per frame. Python&lt;br&gt;
will not finish. Correlation is a convolution, and convolution is one&lt;br&gt;
multiplication in the frequency domain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;correlate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Convolution via FFT. The direct loop does not finish.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;
    &lt;span class="n"&gt;F&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfft2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfft2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kernel&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;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;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;irfft2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;kh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;kh&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="n"&gt;kw&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;# align the origin to top-left
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Score on two things: &lt;strong&gt;misses and over-eager matches.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hit&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;correlate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# stencil ink ∩ frame ink
&lt;/span&gt;    &lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;correlate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ones_like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;      &lt;span class="c1"&gt;# total ink inside the window
&lt;/span&gt;    &lt;span class="n"&gt;ink&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;load&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.35&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ink&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1e-6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ink&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="c1"&gt;# reject misses outright
&lt;/span&gt;    &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unravel_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&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;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&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;hit&lt;/code&gt; on its own latches onto any dense block of dark pixels — a code excerpt,&lt;br&gt;
for instance. Dividing by &lt;code&gt;load&lt;/code&gt; penalises windows carrying ink the stencil&lt;br&gt;
does not account for. The &lt;code&gt;0.35 * ink&lt;/code&gt; term in the denominator keeps the score&lt;br&gt;
from exploding in near-empty regions.&lt;/p&gt;

&lt;p&gt;Feed the per-frame boxes into &lt;code&gt;delogo&lt;/code&gt;'s &lt;code&gt;enable&lt;/code&gt;. Merge runs at the same&lt;br&gt;
position, or you end up with 500 filters and the graph stops building.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three misses
&lt;/h2&gt;
&lt;h3&gt;
  
  
  One — mid-animation
&lt;/h3&gt;

&lt;p&gt;The one above. The fixed box does not land.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two — while it fades up
&lt;/h3&gt;

&lt;p&gt;Tracking still leaked. &lt;strong&gt;The dialog appears translucent first.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;dark()&lt;/code&gt; threshold was 120, so the faint text on those frames did not count&lt;br&gt;
as ink and tracking never started there. Faint text is still readable text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Threshold is 150. At 120 the faint text of the fade-in is dropped,
&lt;/span&gt;    &lt;span class="c1"&gt;# tracking never starts, and those frames pass through untouched.
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also extend each run &lt;strong&gt;0.3 s earlier and 0.2 s later&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three — the browser address bar
&lt;/h3&gt;

&lt;p&gt;This is the one that mattered.&lt;/p&gt;

&lt;p&gt;At the end of the video the agent opens the generated HTML in a browser.&lt;br&gt;
&lt;strong&gt;The address bar showed &lt;code&gt;file:///Users/&amp;lt;my-username&amp;gt;/…&lt;/code&gt; for 23 seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had been staring at dialogs. &lt;strong&gt;It never occurred to me to look there.&lt;/strong&gt;&lt;br&gt;
You do not find what you did not think to look for.&lt;/p&gt;
&lt;h2&gt;
  
  
  So scan the finished file
&lt;/h2&gt;

&lt;p&gt;At that point I stopped trusting the redaction and started scanning the output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;TEMPLATES&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="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;452&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;   &lt;span class="c1"&gt;# write_file  … larger type
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;148.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;850&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;   &lt;span class="c1"&gt;# open_path   … smaller type
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;152.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;940&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;   &lt;span class="c1"&gt;# address bar … different again
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;One stencil is not enough.&lt;/strong&gt; Different dialogs are different widths and set&lt;br&gt;
their text at different sizes. With a single stencil the smaller one never&lt;br&gt;
matched at all and instead latched onto false peaks around 0.30.&lt;/p&gt;

&lt;p&gt;Set the verification threshold higher than the tracking one. Measured: real&lt;br&gt;
hits scored 0.64–0.74, false peaks topped out at 0.46, so 0.55 separates them&lt;br&gt;
cleanly. At 0.45 the scan flagged 179 frames of browser iconography and buried&lt;br&gt;
the real result.&lt;/p&gt;

&lt;p&gt;The scan found &lt;strong&gt;one more leaked frame.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Sample the tracker at the output frame rate
&lt;/h2&gt;

&lt;p&gt;The leak was at 113.933… s.&lt;/p&gt;

&lt;p&gt;I was tracking at 20 fps, so I looked at 113.90 and 113.95. The output is&lt;br&gt;
30 fps, so the frame that actually ships is 113.933…. &lt;strong&gt;Nobody looked at it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# (start, end, frames per second to sample)
&lt;/span&gt;&lt;span class="n"&gt;WINDOWS&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="mf"&gt;113.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;118.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;# animating — sample at the output rate
&lt;/span&gt;    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;150.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;174.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;# static — coarse is fine
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sample the moving stretches at the output rate and leave the static ones&lt;br&gt;
coarse. Running everything at 30 means 4,000 FFTs and no result.&lt;/p&gt;

&lt;p&gt;Final pass: 2,225 frames scanned, nothing found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A fixed rectangle cannot hide something that moves.&lt;/strong&gt; Dialogs animate in&lt;/li&gt;
&lt;li&gt;Track with an FFT correlation; the direct search does not finish&lt;/li&gt;
&lt;li&gt;Score against &lt;strong&gt;both&lt;/strong&gt; misses and over-eager matches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eyeballing it missed three times.&lt;/strong&gt; One was a place I never thought to check&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scan the finished file.&lt;/strong&gt; Sample the tracker at the output frame rate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is needed if the app never puts a username on screen in the first&lt;br&gt;
place, which is the real fix and the one I shipped. &lt;strong&gt;I kept the scan anyway.&lt;/strong&gt;&lt;br&gt;
You do not find what you did not think to look for.&lt;/p&gt;




&lt;p&gt;The app in the recording is &lt;a href="https://rengaworks.gumroad.com/l/wisp" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt; — a&lt;br&gt;
desktop agent with a 3D body that runs commands and writes files, and shows you&lt;br&gt;
exactly what it is about to do before it does it.&lt;/p&gt;

</description>
      <category>ffmpeg</category>
      <category>python</category>
      <category>video</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Where to split a sentence for streaming TTS is decided by one number</title>
      <dc:creator>Renga</dc:creator>
      <pubDate>Tue, 11 Aug 2026 16:29:14 +0000</pubDate>
      <link>https://dev.to/renga154/where-to-split-a-sentence-for-streaming-tts-is-decided-by-one-number-4o6b</link>
      <guid>https://dev.to/renga154/where-to-split-a-sentence-for-streaming-tts-is-decided-by-one-number-4o6b</guid>
      <description>&lt;p&gt;I build a desktop app where you talk to a VRM avatar and it answers out loud. Someone told me the gap between speaking and hearing a reply was too long.&lt;/p&gt;

&lt;p&gt;I measured it and fixed it. Then I found out my benchmark had been running on a machine with a full disk and a load average in the low hundreds, which made the number &lt;strong&gt;4x worse than reality&lt;/strong&gt;, and the design decision I derived from it was wrong for weeks without ever failing.&lt;/p&gt;

&lt;p&gt;The conclusion first: &lt;strong&gt;the split point for streaming TTS follows from the real-time factor, and you should store the formula rather than the number it produces.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Seconds are the wrong unit
&lt;/h2&gt;

&lt;p&gt;You cannot compare TTS engines by how long they took. Longer sentences take longer. What matters is the ratio against the length of the audio produced.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wave&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;wave&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getnframes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getframerate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;rtf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;synth_time&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;   &lt;span class="c1"&gt;# real-time factor
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;r = 1.0&lt;/strong&gt; means 10 seconds of speech takes 10 seconds to synthesize&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;r = 0.25&lt;/strong&gt; means 10 seconds of speech takes 2.5 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every downstream decision comes out of this one number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measurements
&lt;/h2&gt;

&lt;p&gt;VOICEVOX 0.25.2, Apple M4, 10 cores, idle machine.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;chars&lt;/th&gt;
&lt;th&gt;synthesis&lt;/th&gt;
&lt;th&gt;audio length&lt;/th&gt;
&lt;th&gt;r&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.38s&lt;/td&gt;
&lt;td&gt;0.60s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.64&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0.59s&lt;/td&gt;
&lt;td&gt;1.44s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.41&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;1.16s&lt;/td&gt;
&lt;td&gt;4.36s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.26&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;1.74s&lt;/td&gt;
&lt;td&gt;7.71s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.23&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that &lt;strong&gt;longer sentences get a better ratio&lt;/strong&gt;. Short ones carry fixed costs (model setup, leading and trailing silence), so 3 characters at 0.64 is the worst case. That is already telling you not to chop too finely.&lt;/p&gt;

&lt;p&gt;Warm up once before measuring. The first call includes model loading and gives you a number you cannot use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split point falls out of the ratio
&lt;/h2&gt;

&lt;p&gt;If you synthesize the whole reply before playing any of it, the app is silent the entire time. So you cut the first sentence, start playing it, and synthesize the rest while it plays.&lt;/p&gt;

&lt;p&gt;The question is where to cut. Cut early and audio starts sooner. Cut too early and you finish playing the first part before the second part exists, so the sentence has a hole in the middle.&lt;/p&gt;

&lt;p&gt;Let r be the real-time factor, D the total audio length, and p the fraction of the sentence in the first chunk.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;first chunk is synthesized at &lt;code&gt;r*p*D&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;first chunk finishes playing at &lt;code&gt;r*p*D + p*D&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;second chunk is synthesized at &lt;code&gt;r*D&lt;/code&gt; (both chunks run back to back)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No gap requires that the second chunk is ready before the first finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;r*D &amp;lt;= r*p*D + p*D
   -&amp;gt;  p &amp;gt;= r / (1 + r)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;r&lt;/th&gt;
&lt;th&gt;minimum first chunk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;33%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.1&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At r = 0.25 anything past 20% works, so you can cut at an early comma and start talking almost immediately. At r = 1.0 you have to wait until halfway. &lt;strong&gt;The same code breaks or does not break depending on the machine.&lt;/strong&gt;&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;// measured 0.23 to 0.64. short sentences carry fixed cost, so default high&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;splitAtComma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;rtf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;):&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="kr"&gt;string&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;positions&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;text&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;、&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;text&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;positions&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;0&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;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minRatio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rtf&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rtf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// the lower bound, from r&lt;/span&gt;

  &lt;span class="c1"&gt;// earliest comma that satisfies it = earliest possible start&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;pos&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;positions&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;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&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="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;text&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;minRatio&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&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;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the first sentence needs splitting. Everything after it gets synthesized while the previous chunk plays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;My first measurement gave r = 1.09.&lt;/strong&gt; More than 4x worse than the table above. I believed it, concluded that local TTS runs at roughly real time, put r = 1 into &lt;code&gt;p &amp;gt;= r/(1+r)&lt;/code&gt;, and shipped &lt;strong&gt;"split in the middle"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I also tested thread counts and got 1.09 -&amp;gt; 1.47 going to 8 threads, so I concluded there was no headroom in parallelism.&lt;/p&gt;

&lt;p&gt;Both conclusions were wrong. Here is what the machine looked like.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;first run&lt;/th&gt;
&lt;th&gt;re-run&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;load average&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99 to 214&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;free disk&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;833 MB&lt;/strong&gt; (100% used)&lt;/td&gt;
&lt;td&gt;38 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;r&lt;/td&gt;
&lt;td&gt;1.09&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.23 to 0.64&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 threads&lt;/td&gt;
&lt;td&gt;1.47 (worse)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.20 to 0.51 (better)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The disk was full, so swap could not do its job, and the load average was in three digits. &lt;strong&gt;The number was not a lie.&lt;/strong&gt; That is genuinely how long it took at that moment. Which is exactly why I had no reason to doubt it.&lt;/p&gt;

&lt;p&gt;The nasty part is that the wrong conclusion &lt;strong&gt;worked&lt;/strong&gt;. The real lower bound is 20%, so cutting at 50% satisfies it. It was only ever too conservative. It never crashed, never produced a gap, never showed up in a bug report. There was no symptom to chase, so nothing pointed back at the measurement.&lt;/p&gt;

&lt;p&gt;What I take from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check that the machine is idle before benchmarking.&lt;/strong&gt; &lt;code&gt;uptime&lt;/code&gt; and &lt;code&gt;df&lt;/code&gt;. Thirty seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at ratios, not wall clock.&lt;/strong&gt; "4.7 seconds" tells you nothing is wrong. "r = 1.09" is shaped like something you can recognize as absurd.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the formula, not the constant.&lt;/strong&gt; If I had written &lt;code&gt;p &amp;gt;= r/(1+r)&lt;/code&gt; from the start instead of "split in the middle", re-measuring r would have fixed the behaviour everywhere, by itself, the moment I got a clean number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third one is the real lesson. A constant freezes an assumption you made on one particular day on one particular machine. A formula carries the assumption as an input, so correcting the input corrects the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller traps on the way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The playback side needs a queue.&lt;/strong&gt; I sent chunks as they finished and the second one cut off the first, because synthesis is async and chunk two was ready while chunk one was still playing. When you add the queue, watch out that on an HTML audio element &lt;code&gt;ended&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; can &lt;strong&gt;both&lt;/strong&gt; fire for the same clip. If your advance handler is on both without a guard, you silently skip a sentence. It reads like a synthesis bug, so I spent a while in the wrong layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I measured while the previous reply was still playing.&lt;/strong&gt; I was watching a &lt;code&gt;lipSyncAttached&lt;/code&gt; flag to decide when audio had started, and got 0.1 seconds. It was not fast. The previous answer had not finished. Wait for actual silence before starting the next measurement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lipSyncAttached&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Confirm the machine is idle before you measure (&lt;code&gt;uptime&lt;/code&gt;, &lt;code&gt;df&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Use the real-time factor, not seconds&lt;/li&gt;
&lt;li&gt;The split point is &lt;code&gt;p &amp;gt;= r/(1+r)&lt;/code&gt;. At r = 1.0 that is halfway, at 0.25 it is a fifth&lt;/li&gt;
&lt;li&gt;Keep it as a formula so it follows the machine instead of freezing one day's guess&lt;/li&gt;
&lt;li&gt;Queue the playback, and guard against &lt;code&gt;ended&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; both firing&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;These numbers come from building &lt;a href="https://wisp-gules-mu.vercel.app" rel="noopener noreferrer"&gt;Wisp&lt;/a&gt;, a desktop AI agent with a VRM body. &lt;code&gt;p &amp;gt;= r / (1 + r)&lt;/code&gt; is in the shipping code.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>typescript</category>
      <category>ai</category>
      <category>benchmarking</category>
    </item>
  </channel>
</rss>
